diff --git a/.gitignore b/.gitignore index 1e227ca..6b5434f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,13 @@ logs/ .semgrep/ frida_scripts/ config.yaml +test.yaml +config*.yaml +!config.yaml.example +nutcracker.db +nutcracker.db-wal +nutcracker.db-shm +.nutcracker_tmp/ # AI / IDE tool files .code-review-graph/ diff --git a/README.md b/README.md index 13be778..106e094 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,65 @@ 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, fluid WebUSB device video, 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 --- +## 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) @@ -143,6 +194,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 | @@ -162,6 +222,52 @@ pip install -r requirements.txt --- +## 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. @@ -287,6 +393,71 @@ 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 + +# 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 + +# 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 +553,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 +687,118 @@ 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). +- **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** — fluid live video via WebUSB + WebCodecs, opt-in (see below). No fallback: without + WebUSB support the tab just shows why (unsupported browser, or the bundle isn't built yet). +- **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. + +### Fluid device video (WebUSB + WebCodecs) + +For genuinely fluid video (15-30fps, like [app.webadb.com](https://app.webadb.com)), the dashboard +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** (can't reach a +remote/networked device), and it needs a secure context (fine on `localhost`, not on a plain-HTTP +LAN address). Without a supported browser or a built bundle, the Device tab explains why instead +of silently falling back to anything else. 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 +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 +823,13 @@ 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: +> - **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. > **`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 +979,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..b1a6689 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,16 +9,25 @@ ## 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~~ — **removed 2026-07-27** at the user's explicit request, superseded by WebUSB (next item) + the existing screenshot-polling fallback. Was: the dashboard drove the user's own real `scrcpy` binary headlessly (`--no-window --record=.mkv`, re-read via `container.seek()` with PyAV). Had an architectural fps ceiling (~1.3fps even after optimization) and started automatically just from opening the Device tab (an earlier fix in the same session made that explicit-only, right before the feature was removed entirely). See `plan.md` for the full history. +- [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/config.yaml.example b/config.yaml.example index 588851b..2b674e0 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,58 @@ 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 + +# ── 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: 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 +# 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 + +# ── 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 + # Video en vivo del dispositivo: pestaña "Dispositivo" -> "🔌 USB directo + # (fluido)" (WebUSB + WebCodecs, ver nutcracker_core/plugins/dashboard/webusb/). + # 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 +# ── 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/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/owasp-mas-coverage.md b/docs/owasp-mas-coverage.md new file mode 100644 index 0000000..7cd2641 --- /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:** 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 + +| 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 | `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 | `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 | `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 | +| `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) | `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-3` — La app protege las operaciones sensibles con autenticación adicional (step-up) +- `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-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 | — | — | +| `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 | — | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | — | — | +| `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 | +| `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/docs/review-notes.md b/docs/review-notes.md new file mode 100644 index 0000000..52cb746 --- /dev/null +++ b/docs/review-notes.md @@ -0,0 +1,220 @@ +# Notas de revisión — documentos y planes de nutcracker + +> Revisión de los documentos del proyecto (`plan.md`, `ROADMAP.md`, `README.md`, +> `docs/*.md`) contrastados contra el código real. +> Última revisión: 2026-07-27. + +## Alcance + +Se revisaron los siguientes documentos: + +- `plan.md` — 1.403 líneas +- `ROADMAP.md` — 34 líneas +- `README.md` — 984 líneas +- `docs/aipwn-flow.md` — 478 líneas +- `docs/static-analysis-flow.md` — 319 líneas +- `docs/owasp-mas-coverage.md` — 116 líneas + +Y se verificaron inconsistencias contra el código (`orchestrator.py`, +`cli/queue_cmd.py`, `cli/batch.py`, `store/schema.sql`, `nutcracker_core/__init__.py`, +21 archivos de tests en `tests/`). + +Los hallazgos se agrupan en: + +- **(A) Inconsistencias plan↔código** — objetivas y verificadas. +- **(B) Mejoras de estructura y calidad documental** — subjetivas y accionables. + +--- + +## A) Inconsistencias plan↔código (objetivas) + +### A1. `queue rm` no existe, pero está documentado + +- `plan.md` (Fase 1.3) y `README.md` describen `nutcracker queue add|ls|rm`. +- Verificado en `cli/queue_cmd.py`: solo existen `add` y `ls` (líneas 43 y 119). + No hay `rm`/`remove`. +- **Acción:** implementar `queue rm` (borrado de job encolado) o corregir la + documentación para eliminar la mención. + +### A2. `batch --due` no existe, pero está descrito en el plan + +- `plan.md` (Fase 1.3) lo especifica como "modo one-shot para cron/systemd + (encola solo lo vencido y sale)". +- Verificado en `cli/batch.py`: no hay flag `--due` ni lógica de vencidos. +- **Acción:** implementarlo o marcarlo explícitamente como "no implementado / + diferido" en el plan. + +### A3. `schema.sql` incompleto respecto al schema real en runtime + +- `plan.md` (Fase 0.1) presenta `schema.sql`/`db.py` como fuente del esquema. + Pero la tabla `queue_jobs` (añadida en Fase 1) **no está en `schema.sql`** — + solo existe vía migración programática en `db.py`. Un `schema.sql` que alguien + lea pensando que es la verdad del esquema queda desactualizado. +- **Acción:** regenerar `schema.sql` desde el código de migración actual, o + documentar claramente que `schema.sql` es solo el base v1 y las migraciones + viven en `db.py`. + +### A4. `ROADMAP.md` contradice a `plan.md` sobre la verificación de WebUSB + +- `ROADMAP.md` (item WebUSB): *"Not verified: an actual end-to-end USB + connection against a physical device"*. +- `plan.md` Fase 4: *"✅ Confirmado funcionando por el usuario, con video real"* + y *"Fase 4 queda validada de punta a punta contra hardware físico real"*. +- **Acción:** sincronizar ambos documentos (probablemente actualizar el ROADMAP + con el resultado de la validación en vivo del 2026-07-27). + +### A5. Métrica "cobertura OWASP" usada con dos significados distintos + +- `docs/owasp-mas-coverage.md` y `README.md`: **18/24** controles con al menos + un check (correcto y honesto). +- `plan.md` (Fase 2 — estado): *"coverage pasó de 15/24 a 24/24 controles + reportados"*. Ese "24/24" se refiere a "controles que aparecen en el reporte + MASVS" (incluyendo los que salen como `—`/gap), no a "controles con check". + Usar el mismo número (24) con significados opuestos genera confusión. +- **Acción:** en `plan.md` renombrar a *"24/24 controles reportados (18 con + check, 6 como gap)"* para desambiguar. + +### A6. Conteo de tests: 12 números distintos sin fuente única + +- `plan.md` reporta secuencialmente 54 → 73 → 95 → 110 → 119 → 125 → 130 → 167 + → 169 → 181 → 197 → **202** (último). Ningún documento dice "estado actual: + 202 tests, 21 archivos". +- **Acción:** añadir una línea canónica en `ROADMAP.md` o `README.md` + (*"Test suite: 202 tests, 21 files, 100% passing"*) y, si es posible, + actualizarla vía CI. + +--- + +## B) Mejoras de estructura y calidad documental + +### B1. `plan.md` mezcla 4 roles distintos en 1.403 líneas + +Hoy contiene simultáneamente: + +1. El **plan original** (texto prospectivo de Fases 0-3, en presente/futuro). +2. El **estado post-implementación** de cada fase. +3. Una **bitácora de sesiones** con bugs encontrados y diálogos con el usuario. +4. **Notas operacionales** (IPs de dispositivos, jobs reales vistos en DB). + +El texto del plan original no se editó tras implementar, así que convive +"vamos a hacer X" con "ya hicimos X" en el mismo documento. + +- **Acción:** separar en `plan.md` (plan + decisiones, congelado) + + `DECISIONS.md` o `CHANGELOG.md` (bitácora cronológica de lo implementado, + bugs, desviaciones). Esto reduciría `plan.md` a ~400-500 líneas legibles. + +### B2. No existe `CHANGELOG.md` + +Verificado: no hay `CHANGELOG*` en el repo. Toda la evolución vive enterrada en +`plan.md`. Para un proyecto con versiones (`0.2.0`) y entry points publicados, +un changelog formato Keep-a-Changelog es estándar y barato. + +- **Acción:** crear `CHANGELOG.md` con entradas derivadas de las secciones + fechadas (2026-07-24/25/26/27) de `plan.md`. + +### B3. Duplicación/dispersión de flujos entre `docs/*.md` y `plan.md` + +- El flujo del análisis estático vive en `docs/static-analysis-flow.md` (319 + líneas) **y** parcialmente repetido en `plan.md`. +- El flujo de aipwn vive en `docs/aipwn-flow.md` (478 líneas) **y** en `plan.md` + (Fase 3/4). +- Las decisiones de la cola viven en `plan.md` (Fase 1), en `README.md` + (sección Mass Execution), y en `docs/static-analysis-flow.md` (sección + integración con dashboard). +- **Acción:** `plan.md` debería *enlazar* a `docs/*.md` en vez de duplicar; + los `docs/*.md` son el source of truth del "cómo funciona hoy". + +### B4. `ROADMAP.md` es demasiado escaso (34 líneas) frente al `plan.md` (1.403) + +El roadmap público no refleja el estado real del proyecto. Items completados +(cola, scheduler, dashboard, WebUSB, batch desde dashboard) están marcados como +`[x]` solo porque se añadió a posteriori, pero faltan categorías enteras que sí +aparecen en `plan.md` (p. ej. "limitaciones conocidas", "deuda técnica +deliberada"). + +- **Acción:** añadir a `ROADMAP.md` una sección *"Known limitations / deliberate + debt"* que consolide los gaps honestos hoy dispersos: misconfigs no + persistidos en `findings`, solo 2 checks dinámicos, MASWE ausente en manifest + findings, techo de fps en scrcpy file-based, bug del multimodal con z.ai. + +### B5. Falta un diagrama de arquitectura general + +`docs/aipwn-flow.md` y `docs/static-analysis-flow.md` tienen buenos diagramas +ASCII de *flujo de un análisis*, pero no hay una vista de *arquitectura del +sistema* (core `nutcracker_core/{store,queue,scheduler,checks,orchestrator}` +vs plugins `plugins/{aipwn,aireview,dashboard}` vs externos +`adb`/`frida`/`scrcpy`/`semgrep`). La frontera core/plugin se menciona mucho +pero nunca se *dibuja*. + +- **Acción:** un diagrama `docs/architecture.md` con los 3 niveles (core / + plugins / herramientas externas) y las flechas de dependencia. + +### B6. `README.md` (984 líneas) mezcla audiencias + +Contiene instalación (macOS/Linux/Windows), features, arquitectura, ejemplos de +CLI, dashboard, OWASP, todo junto. Es la cara del proyecto pero pesa de leer. + +- **Acción:** mover detalle a `docs/` y dejar en `README.md` un índice + + quickstart + links (target <400 líneas). + +### B7. Inconsistencia menor de URLs de badges + +- `README.md` badges apuntan a `github.com/drneox/nutcracker`. +- El workspace del repo es `github.com/Hiteek/nutcracker` (remote `origin`). +- **Acción:** decidir cuál es el canonical y unificar (o documentar el + fork/mirror). + +### B8. `docs/aipwn-flow.md` lista "27 Herramientas del agente" sin validación + +El detalle dice "27" en el título de la sección 5; sería útil validar ese número +contra `TOOL_SCHEMAS` real (puede haber cambiado tras Fase 3/4) y, si es +automatable, generarlo desde el código como ya se hace con +`owasp-mas-coverage.md`. + +- **Acción:** script `tools/gen_agent_tools.py` (o similar) que regenere esa + lista desde `frida_agent_tools.py`, evitando drift. + +--- + +## Resumen priorizado + +| # | Tipo | Severidad | Esfuerzo | +|---|---|---|---| +| A1 (`queue rm` inexistente) | inconsistencia | media | bajo | +| A2 (`batch --due` inexistente) | inconsistencia | media | medio | +| A4 (ROADMAP contradice plan sobre WebUSB) | inconsistencia | alta | bajo | +| A3 (`schema.sql` incompleto) | inconsistencia | media | bajo | +| A5 (métrica "cobertura" ambigua) | inconsistencia | baja | bajo | +| A6 (conteo de tests sin fuente única) | inconsistencia | baja | bajo | +| B1 (`plan.md` mezcla 4 roles) | mantenibilidad | alta | medio | +| B2 (sin `CHANGELOG`) | mantenibilidad | media | bajo | +| B3 (duplicación de flujos) | mantenibilidad | media | medio | +| B4 (ROADMAP escaso) | calidad externa | media | bajo | +| B5 (sin diagrama de arquitectura) | calidad | media | medio | +| B6 (`README.md` mezcla audiencias) | calidad | baja | medio | +| B7 (URLs de badges) | calidad | baja | bajo | +| B8 (conteo de tools del agente) | calidad | baja | bajo | + +## Orden sugerido de ejecución + +Las de **severidad alta** (A4 y B1) son las que más impactan la confianza de +quien lee: A4 porque dos documentos del mismo repo se contradicen sobre si algo +está verificado, y B1 porque `plan.md` se volvió ilegible en su estado actual +(1.403 líneas que mezclan plan, bitácora y notas operacionales). + +Orden propuesto: + +1. **A4** — sincronizar ROADMAP ↔ plan sobre WebUSB (cambio textual, bajo + esfuerzo). +2. **B2** — crear `CHANGELOG.md` (base para el refactor de B1). +3. **A1 / A2** — decidir implementar o eliminar `queue rm` / `batch --due`. +4. **B1** — refactor de `plan.md` (plan + decisiones vs bitácora). + +## Referencias cruzadas + +- `plan.md` — historial detallado de las Fases 0-4. +- `ROADMAP.md` — ítems pendientes/completados (a sincronizar). +- `docs/static-analysis-flow.md` — flujo del análisis estático. +- `docs/aipwn-flow.md` — flujo del agente aipwn. +- `docs/owasp-mas-coverage.md` — matriz de cobertura MASVS (generada desde el + registry). \ 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.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/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/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..2dda4cd --- /dev/null +++ b/nutcracker_core/checks/dynamic/context.py @@ -0,0 +1,47 @@ +"""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: + # 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 "" + + 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..9908d8b --- /dev/null +++ b/nutcracker_core/checks/static/adapter.py @@ -0,0 +1,128 @@ +"""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_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 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() + _load_manifest_analyzer_checks() diff --git a/nutcracker_core/cli/__init__.py b/nutcracker_core/cli/__init__.py new file mode 100644 index 0000000..c99f80f --- /dev/null +++ b/nutcracker_core/cli/__init__.py @@ -0,0 +1,148 @@ +"""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(_VERSION, 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 +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 new file mode 100644 index 0000000..2caaea9 --- /dev/null +++ b/nutcracker_core/cli/analyze.py @@ -0,0 +1,80 @@ +"""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 / --dynamic-checks (default: first available).", +) +@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`).", +) +@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, 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) + 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..3b0a22a --- /dev/null +++ b/nutcracker_core/cli/batch.py @@ -0,0 +1,226 @@ +"""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 + +import time +from pathlib import Path + +import click + +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) +@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 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 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, + 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) + - 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._init_i18n(config) + + 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" + save_pdf = cfg_get(config, "reports", "save_pdf", default=True) + + 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) + + 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 + + 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] = [] + 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) + + 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.get('error') or '')[:80]})") + + 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/queue_cmd.py b/nutcracker_core/cli/queue_cmd.py new file mode 100644 index 0000000..b70d319 --- /dev/null +++ b/nutcracker_core/cli/queue_cmd.py @@ -0,0 +1,167 @@ +"""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("--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("--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, 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`). + + 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") + + jobs = [] + for t in targets: + try: + 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 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) + + 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}" + ) + + +@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/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..fc72af5 --- /dev/null +++ b/nutcracker_core/cli/scan.py @@ -0,0 +1,207 @@ +"""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", "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", + 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.", +) +@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, 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. + + 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) + + 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: + 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" + + 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, + serial=serial, + ) + + 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/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..6e93ffc --- /dev/null +++ b/nutcracker_core/cli/serve.py @@ -0,0 +1,75 @@ +"""Comando `nutcracker serve`: daemon con cola + scheduler (Fase 1 del plan).""" + +from __future__ import annotations + +import signal +import time + +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 +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)), + ) + # 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() + + 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: + keepalive.stop() + scheduler.stop() + console.print("[dim]nutcracker serve — detenido[/dim]") 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/decompiler.py b/nutcracker_core/decompiler.py index 34ebb70..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,17 +56,36 @@ 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, + config: dict | 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. + + ``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: raise DecompilerError( @@ -64,39 +97,53 @@ 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, 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) + 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) + 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) -> Path: - dest = output_dir / apk_path.stem + +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: @@ -108,18 +155,16 @@ 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, + 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( @@ -129,41 +174,49 @@ 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, + config: dict | 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") + apktool_path = _find_tool("apktool", config) 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)], - 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_{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)], - 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 [ @@ -172,7 +225,7 @@ def extract_manifest(apk_path: Path, output_dir: Path) -> Path | None: ]: 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/downloader.py b/nutcracker_core/downloader.py index d88dc07..76aa15b 100644 --- a/nutcracker_core/downloader.py +++ b/nutcracker_core/downloader.py @@ -5,10 +5,13 @@ import os import re import subprocess +import time import urllib.request 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.""" @@ -260,6 +263,107 @@ def download(self, url: str) -> Path: # ── Google Play (requiere AAS token) ───────────────────────────────────────── +# 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: + """ + 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 _run_adb(self, args: list[str], timeout: int, attempts: int = 3) -> subprocess.CompletedProcess: + """``subprocess.run`` con reintentos ante fallas transitorias del + daemon adb (ver ``_DAEMON_TRANSIENT_MARKERS``) -- un solo intento + alcanza casi siempre, pero conectar WebUSB en el dashboard puede + desestabilizar momentáneamente adb.exe (visto en uso real), y sin + reintentos eso tira abajo el job entero por un problema que se + resuelve solo en 1-2s.""" + last: "subprocess.CompletedProcess | None" = None + for attempt in range(attempts): + last = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + output = (last.stdout or "") + (last.stderr or "") + if not _is_daemon_transient_error(output): + return last + if attempt < attempts - 1: + time.sleep(1.5) + return last # último intento, aunque siga siendo el error transitorio + + def download(self, package_id: str) -> Path: + if self.serial: + adb_transport.ensure_available(self.serial, self.adb_bin) + try: + result = self._run_adb( + self._adb_cmd("shell", "pm", "path", package_id), 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 = self._run_adb( + self._adb_cmd("pull", remote_path, str(local_path)), 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 +403,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 +419,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 +430,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 +452,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/i18n.py b/nutcracker_core/i18n.py index db237f0..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", @@ -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", @@ -1320,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", @@ -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/leak_scanner.py b/nutcracker_core/leak_scanner.py new file mode 100644 index 0000000..99da095 --- /dev/null +++ b/nutcracker_core/leak_scanner.py @@ -0,0 +1,389 @@ +"""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 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 ────────────────────────────────────────────────── + +# 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 _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) -- 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. + """ + 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 and not toolbox.is_enabled(config): + if progress_callback: + progress_callback("apkleaks no encontrado — omitiendo scan de secretos con apkleaks") + return [] + + if progress_callback: + progress_callback("Escaneando secretos con apkleaks...") + + # 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 = _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}") + 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, + 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) -- 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. + """ + gitleaks_bin = shutil.which("gitleaks") + if not gitleaks_bin and not toolbox.is_enabled(config): + if progress_callback: + progress_callback("gitleaks no encontrado — omitiendo scan") + return [] + + if progress_callback: + progress_callback("Escaneando secretos con gitleaks...") + + # 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 = _run_leak_tool( + "gitleaks", gitleaks_bin, + [ + "detect", + "--no-git", + "--no-banner", + "-s", str(source_dir.resolve()), + "-f", "json", + "-r", out_path, + "--log-level", "error", + ], + 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: + 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/manifest_analyzer.py b/nutcracker_core/manifest_analyzer.py index 1834549..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) + 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/masvs.py b/nutcracker_core/masvs.py index cee3c1d..3aa0d05 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,40 +249,172 @@ "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"], "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"], } -# ── 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"], + "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). +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"], + "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 ─────────────────────────────────────────────── +# 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 +425,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 +468,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 +518,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 +532,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 +638,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 +649,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 +672,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 +716,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/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 new file mode 100644 index 0000000..38f98fb --- /dev/null +++ b/nutcracker_core/orchestrator.py @@ -0,0 +1,1729 @@ +"""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 ( + 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, + 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 +# --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: + """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, + 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. + + 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). + + 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. + + ``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] + if aipwn: + # 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 + if is_local_apk: + cmd += ["analyze", target, "--config", config_path] + if dynamic_checks: + cmd.append("--dynamic-checks") + if serial: + 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 + + +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() + 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]") + # 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) + + 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]") + # 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": + """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)) + + # 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. + fire_post_hooks( + "after_analysis", + package=result.package, + result=result, + vuln_scan=vuln_scan, + 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]") + + _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 + # 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" + 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) + elif aipwn_bypass_only: + color = "yellow" + icon = "⚡" + title = t("cli_bypass_confirmed_banner") + detail = t("cli_bypass_confirmed_detail") + 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, + config=_CFG, + ) + + _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_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, config=_CFG) + + 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, + 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, 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, config=_CFG)) + 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", + ) + + _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) + 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, + config=_CFG, + ) + + 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, + config=_CFG, + ) + 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, config=_CFG) + 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]" + ) + + _inject_manifest_component_findings(scan_result) + 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 _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 + 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/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..763e61f --- /dev/null +++ b/nutcracker_core/queue/engine.py @@ -0,0 +1,425 @@ +"""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 re +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 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 + +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") + + +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", + "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.""" + + 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() + # 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 + # 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] = {} + # 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 ───────────────────────────────────────────────────────────── + + def submit(self, target: str, kind: str = "static", serial: str | None = None, + 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( + 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, source=source) + 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 _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: + repository.update_job_status(conn, job.db_id, "running") + 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 + # 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" and not job.source: + 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( + effective_target, + is_local_apk=effective_is_local, + config_path=self.config_path, + static_only=(job.kind == "static"), + 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) + # 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: + 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 + error = "" if ok else _extract_error_summary(_strip_ansi(proc.stderr or proc.stdout or "")) + + 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_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 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="") + + 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"] + # "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] = [] + + 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 \ No newline at end of file diff --git a/nutcracker_core/queue/job.py b/nutcracker_core/queue/job.py new file mode 100644 index 0000000..7370587 --- /dev/null +++ b/nutcracker_core/queue/job.py @@ -0,0 +1,28 @@ +"""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 | 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/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/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/__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..6f05802 --- /dev/null +++ b/nutcracker_core/store/db.py @@ -0,0 +1,92 @@ +"""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 = 2 + +_SCHEMA_PATH = Path(__file__).parent / "schema.sql" + +DEFAULT_DB_PATH = Path(__file__).parent.parent.parent / "nutcracker.db" + +# 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: + """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") + # 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 + + +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..b710cf2 --- /dev/null +++ b/nutcracker_core/store/hooks.py @@ -0,0 +1,174 @@ +"""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 +import os +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") + + # 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 + + 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) + + # 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() + + +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, 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, maswe_ids, cwe_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, + maswe=maswe_ids, + cwe=cwe_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..84c2a48 --- /dev/null +++ b/nutcracker_core/store/repository.py @@ -0,0 +1,276 @@ +"""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() + + +# ── 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 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: + 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/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/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 e2c3e4f..305b79e 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 ─────────────────────────────────────────────────────── @@ -738,35 +702,63 @@ def i18n_rec(self) -> str: 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"], + ), -# ── 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 + # ── 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.", + ), - @property - def critical_count(self) -> int: - return sum(1 for f in self.findings if f.severity == "critical") + # ── 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.", + ), +] - @property - def high_count(self) -> int: - return sum(1 for f in self.findings if f.severity == "high") +# ── Scanner ──────────────────────────────────────────────────────────────────── def scan_directory( source_dir: Path, @@ -1082,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. @@ -1126,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. @@ -1203,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) @@ -1215,6 +1209,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: @@ -1274,15 +1280,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") @@ -1383,354 +1382,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/plan.md b/plan.md new file mode 100644 index 0000000..5abeef9 --- /dev/null +++ b/plan.md @@ -0,0 +1,1912 @@ +# 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**. + +--- + +## 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 | ✅ **Completa** (investigación real + bugs de mapeo corregidos, ver abajo) | +| **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`. + 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 + 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 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` 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`, +`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 + +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 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). + +### 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) + +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`). + +### 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. + +**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 +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) +- **`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`. + +--- + +## 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 — 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,
+**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
+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.
+
+### 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)
+
+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.**
+
+## 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.**
+
+## 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.
+
+---
+
+## 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`.
+
+---
+
+## 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/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..80b5284
--- /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"]
+dev = ["pytest>=8.0.0"]
+
+[project.scripts]
+nutcracker = "nutcracker_core.cli:cli"
+
+[tool.setuptools.packages.find]
+include = ["nutcracker_core*"]
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/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/setup.sh b/setup.sh
index 8805889..4fa7e63 100755
--- a/setup.sh
+++ b/setup.sh
@@ -48,6 +48,53 @@ else
     fi
 fi
 
+DASHBOARD_DIR="nutcracker_core/plugins/dashboard"
+WEBUSB_DIR="$DASHBOARD_DIR/webusb"
+
+# El plugin dashboard (y aipwn) vive bajo nutcracker_core/plugins/, que el
+# .gitignore raíz excluye por completo (regla "plugins/") -- en un checkout
+# fresco del repo puede simplemente no estar presente. Todo lo de abajo es
+# opcional y se salta en silencio si la carpeta no existe.
+if [[ -d "$DASHBOARD_DIR" ]]; then
+    echo ""
+    echo ""
+    echo "==> Instalando dependencias del dashboard web (fastapi/uvicorn)..."
+    if [[ -f "$DASHBOARD_DIR/requirements.txt" ]]; then
+        if pip install -r "$DASHBOARD_DIR/requirements.txt" --quiet; then
+            echo "    OK: 'nutcracker dashboard' listo para usar."
+        else
+            echo "    AVISO: no se pudieron instalar automáticamente. Se reintenta solo (lazy-install)"
+            echo "    la primera vez que corras 'nutcracker dashboard' -- ver plugins/__init__.py."
+        fi
+    fi
+
+    echo ""
+    echo "==> Video USB fluido del dashboard (WebUSB + WebCodecs, único modo -- sin fallback)..."
+    if [[ ! -d "$WEBUSB_DIR" ]]; then
+        echo "    Subproyecto webusb/ no presente en este checkout -- omitiendo (opcional)."
+    elif ! command -v npm &>/dev/null; then
+        echo "    npm NO encontrado -- opcional, solo habilita el video en vivo de la pestaña"
+        echo "    'Dispositivo' (requiere Chrome/Edge + USB directo). Instala Node.js 20+ y corre:"
+        echo "        cd $WEBUSB_DIR && npm install && npm run build"
+        echo "    Detalles/troubleshooting (incluye un problema conocido en WSL): $WEBUSB_DIR/README.md"
+    elif [[ "$(command -v npm)" == /mnt/c/* || "$(command -v node)" == /mnt/c/* ]]; then
+        echo "    AVISO: 'npm'/'node' resuelven al Node de Windows (interop de WSL) -- el build falla"
+        echo "    con errores de rutas UNC. Instala Node nativo de Linux (nvm) y reintenta a mano:"
+        echo "        curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | bash"
+        echo "        nvm install --lts   # y volvé a correr: cd $WEBUSB_DIR && npm install && npm run build"
+        echo "    Detalles: $WEBUSB_DIR/README.md"
+    else
+        echo "    npm detectado, compilando el bundle (puede tardar un minuto)..."
+        if (cd "$WEBUSB_DIR" && npm install --silent && npm run build); then
+            echo "    OK: bundle generado -- el botón '🔌 USB directo (fluido)' aparecerá en el dashboard."
+        else
+            echo "    AVISO: el build falló -- opcional, el resto del dashboard sigue funcionando sin"
+            echo "    el video en vivo. Detalles: $WEBUSB_DIR/README.md"
+        fi
+    fi
+fi
+
+echo ""
 echo "==> ¡Configuración completada!"
 echo ""
 echo "Activa el entorno virtual con:"
@@ -62,3 +109,6 @@ echo "    python nutcracker.py analyze ruta/al/archivo.apk"
 echo ""
 echo "    # Guardar informe JSON:"
 echo "    python nutcracker.py analyze app.apk --report informe.json"
+echo ""
+echo "    # Levantar el dashboard web (cola + apps + video del dispositivo):"
+echo "    python nutcracker.py dashboard"
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..9b6c522
--- /dev/null
+++ b/tests/checks/test_dynamic_checks.py
@@ -0,0 +1,126 @@
+"""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
+
+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
+
+
+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)
+
+
+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
new file mode 100644
index 0000000..6a6c7ad
--- /dev/null
+++ b/tests/checks/test_registry.py
@@ -0,0 +1,116 @@
+"""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", "MANIFEST-LOW-TARGET-SDK"}
+    )
+    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/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/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..18a0ebe
--- /dev/null
+++ b/tests/dashboard/test_api.py
@@ -0,0 +1,279 @@
+"""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_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)
+
+    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_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:
+    deadline = time.monotonic() + timeout
+    while getattr(engine, "_dashboard_draining", False) and time.monotonic() < deadline:
+        time.sleep(0.05)
+    assert not getattr(engine, "_dashboard_draining", False), "el drenado de fondo no terminó a tiempo"
+
+
+def test_queue_batch_rejects_empty_target_list(client):
+    r = client.post("/api/queue/batch", json={"targets": []})
+    assert r.status_code == 400
+
+
+def test_queue_batch_ignores_blank_lines_and_comments(client, engine):
+    r = client.post("/api/queue/batch", json={
+        "targets": ["com.app.one", "", "  # comentario", "com.app.two"],
+        "then_aipwn": False,
+    })
+    assert r.status_code == 200
+    assert r.json()["queued"] == 2
+    _wait_for_drain(engine)
+
+
+def test_queue_batch_chains_aipwn_after_each_successful_static_job(monkeypatch, client, engine):
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        if "scan" in cmd:
+            job_id = int(env["NUTCRACKER_QUEUE_JOB_ID"])
+            pkg = cmd[cmd.index("scan") + 1]
+            conn = db.connect(engine.db_path)
+            try:
+                run_id = repository.insert_run(conn, pkg, kind="static", status="done")
+                repository.link_job_run(conn, job_id, run_id, pkg)
+            finally:
+                conn.close()
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    r = client.post("/api/queue/batch", json={
+        "targets": ["com.app.one", "com.app.two"], "then_aipwn": True,
+    })
+    assert r.status_code == 200
+    assert r.json()["queued"] == 2
+
+    _wait_for_drain(engine)
+
+    jobs = client.get("/api/queue").json()
+    kinds = sorted(j["kind"] for j in jobs)
+    assert kinds == ["aipwn", "aipwn", "static", "static"]
+    aipwn_targets = {j["target"] for j in jobs if j["kind"] == "aipwn"}
+    assert aipwn_targets == {"com.app.one", "com.app.two"}
+
+
+def test_queue_batch_does_not_chain_after_failed_static_job(monkeypatch, client, engine):
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="boom")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    r = client.post("/api/queue/batch", json={"targets": ["com.app.broken"], "then_aipwn": True})
+    assert r.status_code == 200
+
+    _wait_for_drain(engine)
+
+    jobs = client.get("/api/queue").json()
+    assert all(j["kind"] != "aipwn" for j in jobs)
+
+
+def test_queue_batch_propagates_source_and_serial_to_every_submit(monkeypatch, client, engine):
+    calls = []
+    real_submit = engine.submit
+
+    def spy_submit(target, kind="static", serial=None, priority=0, source=None):
+        calls.append({"target": target, "kind": kind, "serial": serial, "source": source})
+        return real_submit(target, kind=kind, serial=serial, priority=priority, source=source)
+
+    monkeypatch.setattr(engine, "submit", spy_submit)
+
+    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/batch", json={
+        "targets": ["com.app.one", "com.app.two"],
+        "source": "device", "serial": "ZY22GPM27J", "then_aipwn": False,
+    })
+    assert r.status_code == 200
+
+    _wait_for_drain(engine)
+
+    assert len(calls) == 2
+    assert all(c["source"] == "device" for c in calls)
+    assert all(c["serial"] == "ZY22GPM27J" for c in calls)
+
+
+
+
+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.
+    r = client.get("/api/agent/prompt")
+    assert r.status_code == 200
+    assert "available" in r.json()
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_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..a56d815
--- /dev/null
+++ b/tests/dashboard/test_ws.py
@@ -0,0 +1,76 @@
+"""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"
+
+
+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_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_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_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_decompiler.py b/tests/test_decompiler.py
new file mode 100644
index 0000000..cffeb41
--- /dev/null
+++ b/tests/test_decompiler.py
@@ -0,0 +1,260 @@
+"""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, 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)
+        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, config=None: "/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, 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])
+        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)
+
+
+# ── 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_downloader_device.py b/tests/test_downloader_device.py
new file mode 100644
index 0000000..6f307ad
--- /dev/null
+++ b/tests/test_downloader_device.py
@@ -0,0 +1,222 @@
+"""Tests de DeviceInstalledDownloader (nutcracker_core/downloader.py).
+
+Extrae el .apk ya instalado en un device conectado vía `adb shell pm path` +
+`adb pull`, sin tocar red -- usado por `nutcracker scan --source device` y
+por jobs de la cola encolados con --source device (ver test_queue_engine.py /
+test_orchestrator.py).
+"""
+
+from __future__ import annotations
+
+import subprocess
+
+import pytest
+
+from nutcracker_core.downloader import (
+    APKDownloadError,
+    DeviceInstalledDownloader,
+    _ensure_network_serial_connected,
+    _is_daemon_transient_error,
+    download_apk_from_config,
+)
+
+
+def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
+    return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
+
+
+def test_download_single_apk_pulls_base_apk(monkeypatch, tmp_path):
+    from pathlib import Path
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        if "path" in cmd:
+            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)
+
+    dl = DeviceInstalledDownloader(output_dir=str(tmp_path), serial="ZY22GPM27J")
+    result = dl.download("com.example.app")
+
+    assert result.name == "base.apk"
+    assert result.exists()
+    # -s  antepuesto a cada invocación de adb
+    assert all("-s" in c and "ZY22GPM27J" in c for c in calls)
+
+
+def test_download_app_bundle_splits_identifies_base_apk(monkeypatch, tmp_path):
+    from pathlib import Path
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        if "path" in cmd:
+            return _completed(stdout=(
+                "package:/data/app/com.example.bundle/base.apk\n"
+                "package:/data/app/com.example.bundle/split_config.arm64_v8a.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)
+
+    dl = DeviceInstalledDownloader(output_dir=str(tmp_path))
+    result = dl.download("com.example.bundle")
+
+    assert result.name == "base.apk"
+    assert (tmp_path / "com.example.bundle" / "split_config.arm64_v8a.apk").exists()
+
+
+def test_download_raises_when_package_not_installed(monkeypatch, tmp_path):
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        return _completed(stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+
+    dl = DeviceInstalledDownloader(output_dir=str(tmp_path), serial="X")
+    with pytest.raises(APKDownloadError, match="no está instalado"):
+        dl.download("com.example.notinstalled")
+
+
+def test_download_raises_when_adb_missing(monkeypatch, tmp_path):
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        raise FileNotFoundError()
+
+    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+
+    dl = DeviceInstalledDownloader(output_dir=str(tmp_path))
+    with pytest.raises(APKDownloadError, match="adb no está instalado"):
+        dl.download("com.example.app")
+
+
+def test_download_raises_when_pull_fails(monkeypatch, tmp_path):
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        if "path" in cmd:
+            return _completed(stdout="package:/data/app/com.example.app/base.apk\n")
+        if "pull" in cmd:
+            return _completed(returncode=1, stderr="adb: error: device offline")
+        return _completed()
+
+    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+
+    dl = DeviceInstalledDownloader(output_dir=str(tmp_path))
+    with pytest.raises(APKDownloadError, match="adb pull"):
+        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.adb_transport.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.adb_transport.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.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
+
+
+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.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")
+
+    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
+
+    calls = {}
+
+    class _FakeDownloader:
+        def __init__(self, output_dir, serial=None):
+            calls["output_dir"] = output_dir
+            calls["serial"] = serial
+
+        def download(self, package_id):
+            calls["package_id"] = package_id
+            p = Path(calls["output_dir"]) / f"{package_id}.apk"
+            p.parent.mkdir(parents=True, exist_ok=True)
+            p.write_bytes(b"PK\x03\x04")
+            return p
+
+    monkeypatch.setattr("nutcracker_core.downloader.DeviceInstalledDownloader", _FakeDownloader)
+
+    result = download_apk_from_config(
+        "com.example.app", config={}, source="device",
+        output_dir=str(tmp_path), serial="ZY22GPM27J",
+    )
+
+    assert calls["serial"] == "ZY22GPM27J"
+    assert calls["package_id"] == "com.example.app"
+    assert result.exists()
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_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/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_orchestrator.py b/tests/test_orchestrator.py
new file mode 100644
index 0000000..c2e097c
--- /dev/null
+++ b/tests/test_orchestrator.py
@@ -0,0 +1,426 @@
+"""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
+
+import os
+
+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
+
+
+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) ────
+
+def test_build_job_cmd_scan_with_device_source_adds_source_and_serial_flags():
+    cmd = orch.build_job_cmd(
+        "com.example.app", is_local_apk=False, static_only=True,
+        source="device", serial="ZY22GPM27J",
+    )
+    assert "scan" in cmd
+    assert "--source" in cmd
+    assert cmd[cmd.index("--source") + 1] == "device"
+    assert "--serial" in cmd
+    assert cmd[cmd.index("--serial") + 1] == "ZY22GPM27J"
+
+
+def test_build_job_cmd_scan_with_device_source_without_serial_omits_serial_flag():
+    cmd = orch.build_job_cmd("com.example.app", is_local_apk=False, source="device")
+    assert "--source" in cmd and "device" in cmd
+    assert "--serial" not in cmd
+
+
+def test_build_job_cmd_scan_without_source_omits_source_flag():
+    cmd = orch.build_job_cmd("com.example.app", is_local_apk=False)
+    assert "--source" not in cmd
+
+
+def test_build_job_cmd_local_apk_ignores_source():
+    """source solo aplica al branch `scan` (target=package id); analyze
+    (target ya es un .apk local) no lo usa -- no debe colarse en el cmd."""
+    cmd = orch.build_job_cmd("/tmp/app.apk", is_local_apk=True, source="device", serial="X")
+    assert "--source" not in cmd
+    assert "analyze" in cmd and "scan" not in cmd
+
+
+# ── _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:
+    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()
+
+
+# ── _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"
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_cmd.py b/tests/test_queue_cmd.py
new file mode 100644
index 0000000..bd3f790
--- /dev/null
+++ b/tests/test_queue_cmd.py
@@ -0,0 +1,145 @@
+"""Tests de `nutcracker queue add` (Fase: batch estático+aipwn desde archivo).
+
+Igual que test_batch.py: el comando se prueba con CliRunner + una QueueEngine
+falsa (sin subprocesos/red/disco real) -- lo que se ejercita aquí es la
+mecánica de --then-aipwn (encadenar aipwn tras cada estático OK) y el flag
+global --source/--serial, no el pipeline de análisis real.
+"""
+
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from nutcracker_core.cli import queue_cmd
+from nutcracker_core.queue.engine import JobOutcome
+from nutcracker_core.queue.job import Job
+
+
+class _FakeEngine:
+    """Doble de QueueEngine: submit() registra la llamada y produce un Job;
+    drain() resuelve todos los jobs pendientes (soporta múltiples llamadas,
+    a diferencia de test_batch.py, porque --then-aipwn encola una segunda
+    tanda tras la primera drain())."""
+
+    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[Job] = []
+        self.submit_calls: list[dict] = []
+        self.on_line = None
+
+    def submit(self, target, kind="static", serial=None, priority=0, source=None):
+        self.submit_calls.append({"target": target, "kind": kind, "serial": serial, "source": source})
+        job = Job(target=target, kind=kind, serial=serial, source=source)
+        job.db_id = len(self.submit_calls)
+        self._pending.append(job)
+        return job
+
+    def drain(self, on_result=None):
+        outcomes = []
+        for job in self._pending:
+            outcome = self._outcomes_by_target[job.target]
+            outcome.job = job  # conserva kind real (static/aipwn) de este submit
+            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_then_aipwn_chains_aipwn_after_each_successful_static_job(tmp_path, monkeypatch):
+    list_file = tmp_path / "targets.txt"
+    list_file.write_text("com.app.one\ncom.app.two\n")
+
+    config_path = tmp_path / "config.yaml"
+    config_path.write_text("queue:\n  static_workers: 4\n")
+
+    outcomes = {
+        "com.app.one": _fake_outcome("com.app.one", ok=True),
+        "com.app.two": _fake_outcome("com.app.two", ok=True),
+    }
+    engine = _FakeEngine(outcomes)
+    monkeypatch.setattr(queue_cmd, "_build_engine", lambda config_path: engine)
+
+    runner = CliRunner()
+    result = runner.invoke(
+        queue_cmd.queue_add,
+        [str(list_file), "--config", str(config_path), "--then-aipwn", "--run"],
+    )
+
+    assert result.exit_code == 0, result.output
+    kinds_submitted = [c["kind"] for c in engine.submit_calls]
+    # 2 estáticos encolados primero, luego 2 aipwn encadenados tras drain() de los estáticos.
+    assert kinds_submitted == ["static", "static", "aipwn", "aipwn"]
+    aipwn_targets = {c["target"] for c in engine.submit_calls if c["kind"] == "aipwn"}
+    assert aipwn_targets == {"com.app.one", "com.app.two"}
+    assert "4/4 OK" in result.output
+
+
+def test_then_aipwn_does_not_chain_after_failed_static_job(tmp_path, monkeypatch):
+    list_file = tmp_path / "targets.txt"
+    list_file.write_text("com.app.broken\ncom.app.ok\n")
+
+    config_path = tmp_path / "config.yaml"
+    config_path.write_text("queue:\n  static_workers: 4\n")
+
+    outcomes = {
+        "com.app.broken": _fake_outcome("com.app.broken", ok=False),
+        "com.app.ok": _fake_outcome("com.app.ok", ok=True),
+    }
+    engine = _FakeEngine(outcomes)
+    monkeypatch.setattr(queue_cmd, "_build_engine", lambda config_path: engine)
+
+    runner = CliRunner()
+    result = runner.invoke(
+        queue_cmd.queue_add,
+        [str(list_file), "--config", str(config_path), "--then-aipwn", "--run"],
+    )
+
+    assert result.exit_code == 0, result.output
+    aipwn_targets = [c["target"] for c in engine.submit_calls if c["kind"] == "aipwn"]
+    assert aipwn_targets == ["com.app.ok"]
+
+
+def test_then_aipwn_rejects_combination_with_dynamic_flag(tmp_path, monkeypatch):
+    list_file = tmp_path / "targets.txt"
+    list_file.write_text("com.app.one\n")
+    engine = _FakeEngine({})
+    monkeypatch.setattr(queue_cmd, "_build_engine", lambda config_path: engine)
+
+    runner = CliRunner()
+    result = runner.invoke(queue_cmd.queue_add, [str(list_file), "--then-aipwn", "--dynamic"])
+
+    assert result.exit_code != 0
+    assert not engine.submit_calls
+
+
+def test_source_and_serial_propagate_to_every_submit_call(tmp_path, monkeypatch):
+    """--source device --serial X (flag global del archivo, no por línea)."""
+    list_file = tmp_path / "targets.txt"
+    list_file.write_text("com.app.one\ncom.app.two\n")
+
+    outcomes = {
+        "com.app.one": _fake_outcome("com.app.one", ok=True),
+        "com.app.two": _fake_outcome("com.app.two", ok=True),
+    }
+    engine = _FakeEngine(outcomes)
+    monkeypatch.setattr(queue_cmd, "_build_engine", lambda config_path: engine)
+
+    runner = CliRunner()
+    result = runner.invoke(
+        queue_cmd.queue_add,
+        [str(list_file), "--source", "device", "--serial", "ZY22GPM27J"],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert len(engine.submit_calls) == 2
+    assert all(c["source"] == "device" for c in engine.submit_calls)
+    assert all(c["serial"] == "ZY22GPM27J" for c in engine.submit_calls)
diff --git a/tests/test_queue_engine.py b/tests/test_queue_engine.py
new file mode 100644
index 0000000..3b98591
--- /dev/null
+++ b/tests/test_queue_engine.py
@@ -0,0 +1,790 @@
+"""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,
+    _extract_error_summary,
+    _resolve_local_apk,
+    _strip_ansi,
+)
+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_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()
+
+    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
+
+
+# ── 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):
+    """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
+
+
+# ── 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
+
+    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"
+
+
+# ── _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"]
+
+
+# ── _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_source_device_builds_scan_with_source_and_serial(monkeypatch, engine):
+    """batch estático+aipwn con --source device (Fase de encolado desde .txt)."""
+    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.app", kind="static", serial="ZY22GPM27J", source="device")
+    outcomes = engine.drain()
+
+    assert len(outcomes) == 1 and outcomes[0].ok
+    cmd = seen_cmds[0]
+    assert "scan" in cmd
+    assert "--source" in cmd and "device" in cmd
+    assert "--serial" in cmd and "ZY22GPM27J" in cmd
+
+
+def test_job_with_explicit_source_skips_local_apk_reuse_heuristic(monkeypatch, tmp_path, engine):
+    """FIX: si el usuario pide explícitamente --source device, no debe
+    silenciarse con un APK viejo que quedó en downloads/ de un intento previo
+    con otra fuente (google-play/apk-pure) -- job.source manda."""
+    pkg = "com.example.stale"
+    dl_dir = tmp_path / "downloads" / pkg
+    dl_dir.mkdir(parents=True)
+    (dl_dir / f"{pkg}.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", source="device", serial="X")
+    outcomes = engine.drain()
+
+    assert len(outcomes) == 1 and outcomes[0].ok
+    cmd = seen_cmds[0]
+    assert "scan" in cmd and "analyze" not in cmd
+    assert "--source" in cmd and "device" 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
+
+# ── 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"}
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
diff --git a/tests/test_store_hooks.py b/tests/test_store_hooks.py
new file mode 100644
index 0000000..412ccfd
--- /dev/null
+++ b/tests/test_store_hooks.py
@@ -0,0 +1,164 @@
+"""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_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}}
+
+    _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_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 == []
diff --git a/tests/test_vuln_scanner_split.py b/tests/test_vuln_scanner_split.py
new file mode 100644
index 0000000..fce9cd0
--- /dev/null
+++ b/tests/test_vuln_scanner_split.py
@@ -0,0 +1,157 @@
+"""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 == []
+
+
+# ── 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}
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()