From 0936d7d1fb86956f9fd4e6057c58f54f806000a0 Mon Sep 17 00:00:00 2001 From: Rafael Steil Date: Wed, 24 Jun 2026 17:49:42 -0400 Subject: [PATCH] feat: Add Cursor project skill and Windows PowerShell script parity Introduce .cursor/skills/android-reverse-engineering for Cursor IDE, document cross-platform setup in README and setup-guide, and add missing PS1 scripts (fingerprint, Kotlin name recovery, lookup). Extract shared Python modules used by bash and PowerShell wrappers; harden decompile.ps1 for XAPK extraction, partial jadx/Fernflower success, and Fernflower timeouts. --- .../android-reverse-engineering/SKILL.md | 154 ++++++++++ .gitignore | 1 + README.md | 139 ++++++--- .../references/setup-guide.md | 84 +++++- .../scripts/decompile.ps1 | 199 ++++++++++--- .../scripts/find-api-calls.ps1 | 183 ++++++++++-- .../scripts/fingerprint.ps1 | 274 ++++++++++++++++++ .../scripts/install-dep.ps1 | 70 ++++- .../scripts/lookup-name.ps1 | 44 +++ .../scripts/lookup-name.sh | 60 +--- .../scripts/lookup_names.py | 93 ++++++ .../scripts/recover-kotlin-names.ps1 | 41 +++ .../scripts/recover-kotlin-names.sh | 103 +------ .../scripts/recover_kotlin_names.py | 112 +++++++ 14 files changed, 1275 insertions(+), 282 deletions(-) create mode 100644 .cursor/skills/android-reverse-engineering/SKILL.md create mode 100644 .gitignore create mode 100644 plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.ps1 create mode 100644 plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.ps1 create mode 100644 plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup_names.py create mode 100644 plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.ps1 create mode 100644 plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover_kotlin_names.py diff --git a/.cursor/skills/android-reverse-engineering/SKILL.md b/.cursor/skills/android-reverse-engineering/SKILL.md new file mode 100644 index 0000000..4b52f57 --- /dev/null +++ b/.cursor/skills/android-reverse-engineering/SKILL.md @@ -0,0 +1,154 @@ +--- +name: android-reverse-engineering +description: Decompiles Android APK, XAPK, JAR, and AAR files using jadx or Fernflower/Vineflower. Reverse engineers Android apps, extracts HTTP API endpoints (Retrofit, OkHttp, Ktor, Apollo, Volley), recovers Kotlin class names from R8 obfuscation, and traces call flows from UI to network layer. Use when the user wants to decompile, analyze, or reverse engineer Android packages, find API endpoints, or follow call flows. Triggers include jadx, fernflower, vineflower, decompile APK, reverse engineer Android, extract API, 反编译APK, 安卓逆向, 提取API. +--- + +# Android Reverse Engineering (Cursor) + +Skill root (repo-relative): + +`plugins/android-reverse-engineering/skills/android-reverse-engineering/` + +Scripts: `plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/` +References: `plugins/android-reverse-engineering/skills/android-reverse-engineering/references/` + +> **Claude Code equivalent:** `${CLAUDE_PLUGIN_ROOT}/skills/android-reverse-engineering/` maps to the skill root above. + +## Platform scripts + +Detect the user's OS and pick the matching script suffix: + +- **Linux / macOS:** use `bash plugins/.../scripts/*.sh` +- **Windows:** use `& plugins/.../scripts/*.ps1` + +Detect via `$env:OS` (Windows), `uname` (Darwin/Linux), or the user's shell. Do not use PowerShell scripts on macOS/Linux unless the user explicitly prefers Git Bash with `.ps1` (rare). + +### Prerequisites + +Tools must be on **PATH** (or standard fallback locations). Required: **Java JDK 17+**, **jadx**. Optional but recommended: Vineflower/Fernflower (`FERNFLOWER_JAR_PATH` for a JAR), dex2jar. + +**Before any decompile work**, run the dependency checker and do not proceed if required tools are missing: + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.sh +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.ps1 +``` + +Output includes machine-readable lines: `INSTALL_REQUIRED:`, `INSTALL_OPTIONAL:`. Exit code `1` means required tools are missing. + +Install missing tools (optional; user may prefer manual PATH setup): + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.sh +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 +``` + +See `plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md` for installation details. + +## Workflow + +Full workflow documentation: `plugins/android-reverse-engineering/skills/android-reverse-engineering/SKILL.md` + +### Phase 0: Fingerprint (recommended first) + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.sh +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.ps1 +``` + +If Flutter / React Native / Cordova / Xamarin is detected, stop; Java decompilation is not the right path. + +### Phase 1: Verify dependencies + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.sh +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.ps1 +``` + +### Phase 2: Decompile + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.sh [OPTIONS] +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 [OPTIONS] +``` + +Options: `-o`/`-Output `, `--deobf`/`-Deobf`, `--no-res`/`-NoRes`, `--engine`/`-Engine jadx|fernflower|both` + +### Phase 3: Analyze structure + +Read `AndroidManifest.xml`, survey `sources/`, grep `BuildConfig.java` files, identify architecture pattern. + +### Phase 3.5: Recover Kotlin names (obfuscated Kotlin apps) + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.sh /sources /mapping +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.sh /mapping --grep '"/api/' /sources +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.ps1 /sources /mapping +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.ps1 /mapping --grep '"/api/' /sources +``` + +### Phase 4: Trace call flows + +See `references/call-flow-analysis.md`. + +### Phase 5: Extract APIs + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.sh /sources/ +``` + +On Windows (PowerShell): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 /sources/ +``` + +Targeted flags: `--retrofit`/`-Retrofit`, `--okhttp`/`-OkHttp`, `--ktor`/`-Ktor`, `--apollo`/`-Apollo`, `--volley`/`-Volley`, `--urls`/`-Urls`, `--paths`/`-Paths`, `--auth`/`-Auth` + +Produce Tier 1 inventory table for all endpoints; Tier 2 detail only for high-value endpoints (auth, payments, user-requested). + +## References + +- `references/setup-guide.md`: tool installation +- `references/jadx-usage.md`: jadx CLI +- `references/fernflower-usage.md`: Fernflower/Vineflower CLI +- `references/api-extraction-patterns.md`: search patterns +- `references/kotlin-name-recovery.md`: R8 name recovery +- `references/call-flow-analysis.md`: tracing techniques + +## Output deliverables + +1. Decompiled source in output directory +2. Architecture summary +3. API documentation (Tier 1 table + Tier 2 for key endpoints) +4. Call flow map (auth and main features) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..674c23b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*-decompiled/ diff --git a/README.md b/README.md index 9ff64e1..73c16b4 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ -# Android Reverse Engineering & API Extraction — Claude Code skill +# Android Reverse Engineering & API Extraction [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![GitHub stars](https://img.shields.io/github/stars/SimoneAvogadro/android-reverse-engineering-skill?style=social)](https://github.com/SimoneAvogadro/android-reverse-engineering-skill/stargazers) [![GitHub last commit](https://img.shields.io/github/last-commit/SimoneAvogadro/android-reverse-engineering-skill)](https://github.com/SimoneAvogadro/android-reverse-engineering-skill/commits/master) -A Claude Code skill that decompiles Android APK/XAPK/JAR/AAR files and **extracts the HTTP APIs** used by the app — Retrofit endpoints, OkHttp calls, hardcoded URLs, authentication patterns — so you can document and reproduce them without the original source code. +A skill for Claude Code and **Cursor** that decompiles Android APK/XAPK/JAR/AAR files and **extracts the HTTP APIs** used by the app (Retrofit endpoints, OkHttp calls, hardcoded URLs, authentication patterns) so you can document and reproduce them without the original source code. -> **First-class Kotlin support**: modern Android apps are Kotlin/KMP, heavily obfuscated with R8. This skill recovers the **original Kotlin class names** from metadata R8 cannot strip, and extracts APIs from **Ktor**, **Apollo (GraphQL)** and **Koin** — not just the classic Retrofit/OkHttp stack. See [Kotlin name recovery](#kotlin-name-recovery-r8-deobfuscation) below. +> **First-class Kotlin support**: modern Android apps are Kotlin/KMP, heavily obfuscated with R8. This skill recovers the **original Kotlin class names** from metadata R8 cannot strip, and extracts APIs from **Ktor**, **Apollo (GraphQL)** and **Koin**, not just the classic Retrofit/OkHttp stack. See [Kotlin name recovery](#kotlin-name-recovery-r8-deobfuscation) below. -> **Windows / PowerShell support (experimental)**: The `*.ps1` scripts alongside the bash ones are a recent community contribution, still being stabilised. For any issues please open an issue on **this** repository (not on the contributors' upstream forks): the PowerShell scripts are maintained here by [@SimoneAvogadro](https://github.com/SimoneAvogadro). +> **Cross-platform scripts**: Bash scripts on Linux/macOS and native PowerShell (`*.ps1`) on Windows cover the full workflow: fingerprint, decompile, API extraction, and Kotlin name recovery. See [Usage](#usage) for examples on each platform. -## Table of Contents +# Table of Contents - [What it does](#what-it-does) - [Requirements](#requirements) - [Installation](#installation) + - [Claude Code](#claude-code) + - [Cursor](#cursor) - [Usage](#usage) - [Repository Structure](#repository-structure) - [References](#references) @@ -20,19 +22,19 @@ A Claude Code skill that decompiles Android APK/XAPK/JAR/AAR files and **extract - [Disclaimer](#disclaimer) - [License](#license) -## What it does +# What it does | Capability | Description | |------------|-------------| -| **Fingerprint first (Phase 0)** | Triage an APK/XAPK in seconds — detect the framework (Flutter / React Native / Cordova / Xamarin / native-Kotlin), HTTP stack, obfuscation level and native libs *before* spending time on a full decompile | +| **Fingerprint first (Phase 0)** | Triage an APK/XAPK in seconds: detect the framework (Flutter / React Native / Cordova / Xamarin / native-Kotlin), HTTP stack, obfuscation level and native libs *before* spending time on a full decompile | | **Decompile** | APK, XAPK, JAR, and AAR files using jadx and Fernflower/Vineflower (single engine or side-by-side comparison) | | **Recover Kotlin names** | Rebuild original `*Repository` / `*ViewModel` / `*UseCase` class names from R8-obfuscated binaries using Kotlin metadata that R8 cannot strip | -| **Extract APIs** | Retrofit, OkHttp, Volley **and modern Kotlin/KMP stacks: Ktor, Apollo (GraphQL), Koin DI** — endpoints, hardcoded URLs, auth headers, tokens and HMAC request-signing schemes | +| **Extract APIs** | Retrofit, OkHttp, Volley **and modern Kotlin/KMP stacks: Ktor, Apollo (GraphQL), Koin DI**; endpoints, hardcoded URLs, auth headers, tokens and HMAC request-signing schemes | | **Trace call flows** | From Activities/Fragments through ViewModels and repositories down to HTTP calls | | **Analyze structure** | Manifest, packages, architecture patterns | | **Handle obfuscation** | R8-resistant path/URL extraction plus strategies for navigating ProGuard/R8 output | -## Requirements +# Requirements **Required:** @@ -41,12 +43,14 @@ A Claude Code skill that decompiles Android APK/XAPK/JAR/AAR files and **extract **Optional (recommended):** -- [Vineflower](https://github.com/Vineflower/vineflower) or [Fernflower](https://github.com/JetBrains/fernflower) — better output on complex Java code -- [dex2jar](https://github.com/ThexXTURBOXx/dex2jar) — needed to use Fernflower on APK/DEX files +- [Vineflower](https://github.com/Vineflower/vineflower) or [Fernflower](https://github.com/JetBrains/fernflower): better output on complex Java code +- [dex2jar](https://github.com/ThexXTURBOXx/dex2jar): needed to use Fernflower on APK/DEX files See `plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md` for detailed installation instructions. -## Installation +# Installation + +## Claude Code ### From GitHub (recommended) @@ -72,9 +76,32 @@ Then in Claude Code: /plugin install android-reverse-engineering@android-reverse-engineering-skill ``` -## Usage +## Cursor + +Cursor loads **project skills** from `.cursor/skills/` in the **workspace root** — the folder you open in Cursor, not every repo on disk. + +Clone this repository, then pick one setup: + +| Goal | What to do | +|------|------------| +| **Use the skill here** (simplest) | Open this repo as your Cursor workspace. Put APKs anywhere on disk and reference them by path. | +| **Use the skill in another project** | Copy (or git submodule) **both** `.cursor/skills/android-reverse-engineering/` **and** `plugins/android-reverse-engineering/` into that project's repo root, then open **that** project in Cursor. The skill's `SKILL.md` references scripts under `plugins/…` relative to the workspace root — copying only `SKILL.md` is not enough. | +| **Both projects at once** | Add this repo and your other project to a [multi-root workspace](https://code.visualstudio.com/docs/editor/workspaces#_multiroot-workspaces). | +| **All projects (advanced)** | Install under `~/.cursor/skills/android-reverse-engineering/`, but adjust script paths in `SKILL.md` to a fixed location on disk (e.g. where you cloned this repo). | + +The project skill lives at `.cursor/skills/android-reverse-engineering/` and points to the shared scripts under `plugins/android-reverse-engineering/skills/android-reverse-engineering/`. -### Slash command +1. Ensure **Java 17+**, **jadx**, and optionally **dex2jar** / **Vineflower** are on your PATH (see [setup-guide](plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md)). +2. Verify dependencies (run from the workspace root that contains `plugins/`): + - **Linux / macOS:** `bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.sh` + - **Windows:** `& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.ps1` +3. Invoke via `@android-reverse-engineering` or phrases like "decompile this APK". + +Unlike Claude Code, Cursor has no global `/plugin install` — the skill is available only in workspaces where `.cursor/skills/android-reverse-engineering/` is present (unless you use the personal-skill path above). + +# Usage + +## Slash command ```text /decompile path/to/app.apk @@ -82,7 +109,7 @@ Then in Claude Code: This runs the full workflow: dependency check, decompilation, and initial structure analysis. -### Natural language +## Natural language The skill activates on phrases like: @@ -92,9 +119,7 @@ The skill activates on phrases like: - "Follow the call flow from LoginActivity" - "Analyze this AAR library" -### Manual scripts - -The scripts can also be used standalone: +## Manual scripts (bash on Linux/macOS) ```bash # Check dependencies @@ -120,7 +145,7 @@ bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scri # Run both engines and compare bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.sh --engine both --deobf app.apk -# Find API calls — defaults to a full scan across every supported stack +# Find API calls (defaults to a full scan across every supported stack) bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.sh output/sources/ bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.sh output/sources/ --retrofit bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.sh output/sources/ --urls @@ -131,16 +156,31 @@ bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scri bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.sh output/sources/ --paths # quoted path literals that survive R8 inlining ``` -### Kotlin name recovery (R8 deobfuscation) +## Manual scripts (PowerShell on Windows) + +```powershell +# Check dependencies +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.ps1 + +# Fingerprint before decompiling (Phase 0) +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.ps1 app.apk + +# Decompile +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 app.apk +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 -Engine both -Deobf app.apk + +# Find API calls +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 output/sources/ +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 output/sources/ -Ktor -Apollo -Paths + +# Kotlin name recovery +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.ps1 output/sources output/names +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.ps1 output/names LoginRepository +``` + +## Kotlin name recovery (R8 deobfuscation) -Most real-world Kotlin/KMP apps ship through R8, so the decompiled classes come -out as `a.b.c`. R8 renames the JVM symbols but **cannot strip the Kotlin -metadata strings** — the Kotlin runtime (reflection, coroutines) needs the -original fully-qualified names at runtime. This skill mines those -`@DebugMetadata` / `@Metadata` annotations to rebuild an `obfuscated → real` -class-name map. On a typical app it recovers ~100 % of the -`*Repository` / `*ViewModel` / `*UseCase` / `*Impl` classes you actually want to -read. +Most real-world Kotlin/KMP apps ship through R8, so the decompiled classes come out as `a.b.c`. R8 renames the JVM symbols but **cannot strip the Kotlin metadata strings**; the Kotlin runtime (reflection, coroutines) needs the original fully-qualified names at runtime. This skill mines those `@DebugMetadata` / `@Metadata` annotations to rebuild an `obfuscated → real` class-name map. On a typical app it recovers ~100 % of the `*Repository` / `*ViewModel` / `*UseCase` / `*Impl` classes you actually want to read. ```bash # 1. Build the mapping from the decompiled sources @@ -154,7 +194,7 @@ bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scri bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.sh output/names/ --grep 'login' output/sources/ ``` -## Repository Structure +# Repository Structure ```text android-reverse-engineering-skill/ @@ -182,36 +222,45 @@ android-reverse-engineering-skill/ │ │ ├── install-dep.ps1 │ │ ├── decompile.sh │ │ ├── decompile.ps1 -│ │ ├── fingerprint.sh # Phase 0 — pre-decompile triage +│ │ ├── fingerprint.sh # Phase 0: pre-decompile triage +│ │ ├── fingerprint.ps1 │ │ ├── recover-kotlin-names.sh # R8 → real Kotlin class names +│ │ ├── recover-kotlin-names.ps1 +│ │ ├── recover_kotlin_names.py │ │ ├── lookup-name.sh # query the recovered name map +│ │ ├── lookup-name.ps1 +│ │ ├── lookup_names.py │ │ ├── find-api-calls.sh │ │ └── find-api-calls.ps1 │ └── commands/ │ └── decompile.md # /decompile slash command +├── .cursor/ +│ └── skills/ +│ └── android-reverse-engineering/ +│ └── SKILL.md # Cursor project skill (thin wrapper) ├── LICENSE └── README.md ``` -## References +# References -- [jadx — Dex to Java decompiler](https://github.com/skylot/jadx) -- [Fernflower — JetBrains analytical decompiler](https://github.com/JetBrains/fernflower) -- [Vineflower — Fernflower community fork](https://github.com/Vineflower/vineflower) -- [dex2jar — DEX to JAR converter](https://github.com/ThexXTURBOXx/dex2jar) -- [apktool — Android resource decoder](https://apktool.org/) +- [jadx: Dex to Java decompiler](https://github.com/skylot/jadx) +- [Fernflower: JetBrains analytical decompiler](https://github.com/JetBrains/fernflower) +- [Vineflower: Fernflower community fork](https://github.com/Vineflower/vineflower) +- [dex2jar: DEX to JAR converter](https://github.com/ThexXTURBOXx/dex2jar) +- [apktool: Android resource decoder](https://apktool.org/) -## Acknowledgments +# Acknowledgments Thanks to the contributors who have shaped this skill: -- [@tajchert](https://github.com/tajchert) — Phase 0 fingerprinting, R8-resistant Kotlin name recovery (`recover-kotlin-names.sh`, `lookup-name.sh`), and Ktor / Apollo / Koin / HMAC extraction patterns (#16) -- [@philjn](https://github.com/philjn) — Native Windows / PowerShell support (`check-deps.ps1`, `install-dep.ps1`, `decompile.ps1`, `find-api-calls.ps1`) and split/bundled APK detection in `decompile.sh` (#8) -- [@txhno](https://github.com/txhno) — Migration to the maintained [`ThexXTURBOXx/dex2jar`](https://github.com/ThexXTURBOXx/dex2jar) fork (#12) -- [@muqiao215](https://github.com/muqiao215) — Decompile partial-success handling, Fernflower timeout safeguard, intermediate-artifact directory (#10) -- [@kevinaimonster](https://github.com/kevinaimonster) — Chinese localization (`SKILL.md` discovery keywords) (#4) +- [@tajchert](https://github.com/tajchert): Phase 0 fingerprinting, R8-resistant Kotlin name recovery (`recover-kotlin-names.sh`, `lookup-name.sh`), and Ktor / Apollo / Koin / HMAC extraction patterns (#16) +- [@philjn](https://github.com/philjn): Native Windows / PowerShell support (`check-deps.ps1`, `install-dep.ps1`, `decompile.ps1`, `find-api-calls.ps1`) and split/bundled APK detection in `decompile.sh` (#8) +- [@txhno](https://github.com/txhno): Migration to the maintained [`ThexXTURBOXx/dex2jar`](https://github.com/ThexXTURBOXx/dex2jar) fork (#12) +- [@muqiao215](https://github.com/muqiao215): Decompile partial-success handling, Fernflower timeout safeguard, intermediate-artifact directory (#10) +- [@kevinaimonster](https://github.com/kevinaimonster): Chinese localization (`SKILL.md` discovery keywords) (#4) -## Disclaimer +# Disclaimer This plugin is provided strictly for **lawful purposes**, including but not limited to: @@ -224,6 +273,6 @@ This plugin is provided strictly for **lawful purposes**, including but not limi The authors disclaim any liability for misuse of this tool. -## License +# License -Apache 2.0 — see [LICENSE](LICENSE) +Apache 2.0; see [LICENSE](LICENSE) diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md b/plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md index 68f09bf..a5b02e7 100644 --- a/plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/references/setup-guide.md @@ -35,6 +35,22 @@ After installation on macOS, follow the symlink instructions printed by Homebrew export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH" ``` +### Windows (winget) + +From PowerShell or Command Prompt: + +```powershell +winget install --id Microsoft.OpenJDK.17 --accept-source-agreements --accept-package-agreements +``` + +Microsoft OpenJDK 17 is added to PATH automatically. If `java` is not found afterward, open a new terminal and run the verify step below. + +Alternatively, run the skill's install script (uses winget when available): + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 java +``` + ### Verify ```bash @@ -86,7 +102,7 @@ jadx --version ## Fernflower / Vineflower (optional, recommended) -Fernflower is the JetBrains Java decompiler. It produces better output than jadx on complex Java constructs, lambdas, and generics. [Vineflower](https://github.com/Vineflower/vineflower) is the actively maintained community fork with published releases — prefer it over upstream Fernflower. +Fernflower is the JetBrains Java decompiler. It produces better output than jadx on complex Java constructs, lambdas, and generics. [Vineflower](https://github.com/Vineflower/vineflower) is the actively maintained community fork with published releases; prefer it over upstream Fernflower. ### Option 1: Vineflower from GitHub Releases (recommended) @@ -208,14 +224,76 @@ adb pull /data/app/com.example.app-xxxx/base.apk ./app.apk --- +## Linux / macOS (bash) + +On Linux and macOS, use the `*.sh` scripts in `scripts/`. Tools must be on **PATH** before running the skill (or in standard fallback locations; see `check-deps.sh`). + +### Verify dependencies + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.sh +``` + +### Auto-install (optional) + +```bash +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.sh jadx +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.sh vineflower +bash plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.sh dex2jar +``` + +The install script detects the OS and package manager, installs without sudo when possible (downloads to `~/.local/share/`, symlinks in `~/.local/bin/`), or uses apt/dnf/pacman/brew when necessary. + +--- + +## Windows (PowerShell) + +On Windows, use the `*.ps1` scripts in `scripts/`. Tools must be on **PATH** before running the skill (or in standard fallback locations; see `check-deps.ps1`). + +### Required PATH entries + +| Tool | What to add to PATH | +|------|---------------------| +| Java JDK 17+ | Java `bin` directory (often already on PATH after install) | +| jadx | `jadx\bin` directory (contains `jadx.bat`) | +| dex2jar (optional) | dex-tools root directory (contains `d2j-dex2jar.bat`) | + +For Vineflower, set an environment variable instead of PATH: + +```powershell +$env:FERNFLOWER_JAR_PATH = 'C:\path\to\vineflower.jar' +``` + +### Verify dependencies + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/check-deps.ps1 +``` + +### Auto-install (optional) + +```powershell +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 jadx +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 vineflower +& plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 dex2jar +``` + +The install script uses `winget`, `scoop`, or `choco` when available, or downloads to `%USERPROFILE%\.local\share\`. + +### Cursor IDE + +This repository includes a project skill at `.cursor/skills/android-reverse-engineering/`. Invoke it via `@android-reverse-engineering` or natural-language triggers (e.g. "decompile this APK"). The skill reuses scripts and references under `plugins/android-reverse-engineering/skills/android-reverse-engineering/`; no duplication. Use the bash scripts on Linux/macOS and the PowerShell scripts on Windows. + +--- + ## Troubleshooting | Problem | Solution | |---|---| | `jadx: command not found` | Ensure the jadx `bin/` directory is in your `$PATH` | -| `Error: Could not find or load main class` | Java is missing or wrong version — verify with `java -version` | +| `Error: Could not find or load main class` | Java is missing or wrong version; verify with `java -version` | | jadx runs out of memory on large APKs | Increase heap: `jadx -Xmx4g -d output app.apk` or set `JAVA_OPTS="-Xmx4g"` | | Decompiled code has many `// Error` comments | Try `--show-bad-code` to see partial output, or use `--deobf` for obfuscated apps | | Fernflower hangs on a method | Use `-mpm=60` to set a 60-second timeout per method | | Fernflower JAR not found | Set `FERNFLOWER_JAR_PATH` env variable to the full path of the JAR | -| dex2jar fails with `ZipException` | The APK may have a non-standard ZIP structure — try `jadx` instead | +| dex2jar fails with `ZipException` | The APK may have a non-standard ZIP structure; try `jadx` instead | diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 index 684c419..31c5a4e 100644 --- a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/decompile.ps1 @@ -1,4 +1,4 @@ -# decompile.ps1 — Decompile APK/XAPK/JAR/AAR using jadx, fernflower, or both +# decompile.ps1: Decompile APK/XAPK/JAR/AAR using jadx, fernflower, or both param( [Alias('o')] [string]$Output, @@ -48,7 +48,8 @@ Engines: fernflower -> /fernflower/ Environment: - FERNFLOWER_JAR_PATH Path to fernflower.jar or vineflower.jar + FERNFLOWER_JAR_PATH Path to fernflower.jar or vineflower.jar + FERNFLOWER_TIMEOUT_SECONDS Max seconds for Fernflower run (default: 900) Examples: .\decompile.ps1 app-release.apk @@ -95,10 +96,12 @@ $xapkExtractedDir = $null $xapkApkFiles = @() if ($extLower -eq 'xapk') { + # Expand-Archive doesn't work on .xapk; use ZipFile $xapkExtractedDir = Join-Path $env:TEMP "xapk-extract-$(Get-Random)" Write-Host "=== Extracting XAPK archive ===" New-Item -ItemType Directory -Path $xapkExtractedDir -Force | Out-Null - Expand-Archive -Path $inputFileAbs -DestinationPath $xapkExtractedDir -Force + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($inputFileAbs, $xapkExtractedDir) # Show manifest.json if present $manifestPath = Join-Path $xapkExtractedDir 'manifest.json' @@ -151,13 +154,13 @@ function Find-Dex2Jar { return $null } -# --- jadx decompilation --- +# Returns 0 success, 1 hard failure, 2 partial success (exit non-zero but .java files produced) function Invoke-Jadx { param([string]$OutDir, [string]$FileAbs, [string]$FileExt) if (-not (Get-Command jadx -ErrorAction SilentlyContinue)) { Write-Host "Error: jadx is not installed or not in PATH." -ForegroundColor Red - return $false + return 1 } $jadxArgs = @('-d', $OutDir) @@ -168,89 +171,198 @@ function Invoke-Jadx { Write-Host "Running: jadx $($jadxArgs -join ' ')" & jadx @jadxArgs + $jadxStatus = $LASTEXITCODE $sourcesDir = Join-Path $OutDir 'sources' + $count = 0 if (Test-Path $sourcesDir) { - $count = (Get-ChildItem -Path $sourcesDir -Recurse -Filter '*.java').Count + $count = (Get-ChildItem -Path $sourcesDir -Recurse -Filter '*.java' -File -ErrorAction SilentlyContinue).Count Write-Host "jadx output: $sourcesDir\" Write-Host "Java files decompiled by jadx: $count" } - return $true + + if ($jadxStatus -eq 0) { return 0 } + if ($count -gt 0) { + Write-Host "Warning: jadx exited with status $jadxStatus after writing $count Java files; treating this as partial success." -ForegroundColor Yellow + return 2 + } + Write-Host "Error: jadx failed with status $jadxStatus and produced no Java output." -ForegroundColor Red + return 1 +} + +function Invoke-JavaJarWithTimeout { + param( + [string]$JarPath, + [string[]]$Args, + [int]$TimeoutSeconds + ) + $argList = @('-jar', $JarPath) + $Args + $escaped = $argList | ForEach-Object { + if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } + } + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'java' + $psi.Arguments = $escaped -join ' ' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $proc = [System.Diagnostics.Process]::Start($psi) + $stdout = $proc.StandardOutput.ReadToEnd() + $stderr = $proc.StandardError.ReadToEnd() + $timedOut = -not $proc.WaitForExit($TimeoutSeconds * 1000) + if ($timedOut) { + try { $proc.Kill() } catch { } + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + return 124 + } + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + return $proc.ExitCode } -# --- Fernflower decompilation --- +# Returns 0 success, 1 hard failure, 2 partial success function Invoke-Fernflower { - param([string]$OutDir, [string]$FileAbs, [string]$FileExt) + param([string]$OutDir, [string]$FileAbs, [string]$FileExt, [string]$BaseNameForJar) $ffJar = Find-FernflowerJar if (-not $ffJar) { Write-Host "Error: Fernflower/Vineflower JAR not found." -ForegroundColor Red Write-Host "Set FERNFLOWER_JAR_PATH or see references/setup-guide.md" - return $false + return 1 } New-Item -ItemType Directory -Path $OutDir -Force | Out-Null $jarToDecompile = $FileAbs $convertedJar = $null + $intermediateDir = Join-Path $OutDir 'intermediate' + $ffTimeoutSeconds = 900 + if ($env:FERNFLOWER_TIMEOUT_SECONDS -match '^\d+$' -and [int]$env:FERNFLOWER_TIMEOUT_SECONDS -gt 0) { + $ffTimeoutSeconds = [int]$env:FERNFLOWER_TIMEOUT_SECONDS + } - # For APK/AAR, we need dex2jar first if ($FileExt -in @('apk', 'aar')) { $d2j = Find-Dex2Jar if (-not $d2j) { Write-Host "Error: dex2jar is required to use Fernflower on .$FileExt files." -ForegroundColor Red Write-Host "Install dex2jar - see references/setup-guide.md" - return $false + return 1 } Write-Host "Converting $FileExt to JAR with dex2jar..." - $convertedJar = Join-Path $OutDir "$baseName-dex2jar.jar" + New-Item -ItemType Directory -Path $intermediateDir -Force | Out-Null + $jarBase = if ($BaseNameForJar) { $BaseNameForJar } else { $baseName } + $convertedJar = Join-Path $intermediateDir "$jarBase-dex2jar.jar" & $d2j -f -o $convertedJar $FileAbs 2>&1 | Write-Host + $d2jStatus = $LASTEXITCODE if (-not (Test-Path $convertedJar)) { - Write-Host "Error: dex2jar conversion failed." -ForegroundColor Red - return $false + Write-Host "Error: dex2jar conversion failed with status $d2jStatus." -ForegroundColor Red + return 1 + } + if ($d2jStatus -ne 0) { + Write-Host "Warning: dex2jar exited with status $d2jStatus but produced $convertedJar; continuing." -ForegroundColor Yellow } $jarToDecompile = $convertedJar } - # Build fernflower args $ffArgs = @('-dgs=1', '-mpm=60') if ($Deobf) { $ffArgs += '-ren=1' } $ffArgs += $jarToDecompile $ffArgs += $OutDir Write-Host "Running: java -jar $ffJar $($ffArgs -join ' ')" - & java -jar $ffJar @ffArgs + Write-Host "Fernflower timeout: ${ffTimeoutSeconds}s (override with FERNFLOWER_TIMEOUT_SECONDS)" + $ffStatus = Invoke-JavaJarWithTimeout -JarPath $ffJar -Args $ffArgs -TimeoutSeconds $ffTimeoutSeconds - # Fernflower outputs a JAR containing .java files — extract it + $sourcesDir = Join-Path $OutDir 'sources' $resultJar = Join-Path $OutDir ([IO.Path]::GetFileName($jarToDecompile)) if (Test-Path $resultJar) { - $sourcesDir = Join-Path $OutDir 'sources' New-Item -ItemType Directory -Path $sourcesDir -Force | Out-Null - Expand-Archive -Path $resultJar -DestinationPath $sourcesDir -Force - Remove-Item $resultJar -Force - $count = (Get-ChildItem -Path $sourcesDir -Recurse -Filter '*.java').Count + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + [System.IO.Compression.ZipFile]::ExtractToDirectory($resultJar, $sourcesDir) + Remove-Item $resultJar -Force + } catch { + Write-Host "Warning: Fernflower result jar $resultJar could not be extracted; checking for direct folder output." -ForegroundColor Yellow + } + } + + New-Item -ItemType Directory -Path $sourcesDir -Force | Out-Null + $count = (Get-ChildItem -Path $sourcesDir -Recurse -Filter '*.java' -File -ErrorAction SilentlyContinue).Count + + # Vineflower may write sources directly into the destination folder tree + if ($count -eq 0) { + $directEntries = Get-ChildItem -Path $OutDir -Directory | + Where-Object { $_.Name -notin @('sources', 'intermediate') } + $directCount = 0 + foreach ($entry in $directEntries) { + $directCount += (Get-ChildItem -Path $entry.FullName -Recurse -Filter '*.java' -File -ErrorAction SilentlyContinue).Count + } + if ($directCount -gt 0) { + foreach ($entry in $directEntries) { + Get-ChildItem -Path $entry.FullName -Recurse -File | ForEach-Object { + $rel = $_.FullName.Substring($entry.FullName.Length).TrimStart('\') + $dest = Join-Path $sourcesDir $rel + $destDir = Split-Path $dest -Parent + if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + Move-Item -Path $_.FullName -Destination $dest -Force + } + if ((Get-ChildItem -Path $entry.FullName -Recurse -ErrorAction SilentlyContinue).Count -eq 0) { + Remove-Item $entry.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + } + $count = (Get-ChildItem -Path $sourcesDir -Recurse -Filter '*.java' -File -ErrorAction SilentlyContinue).Count + } + } + + if ($count -gt 0) { Write-Host "Fernflower output: $sourcesDir\" Write-Host "Java files decompiled by Fernflower: $count" + if ($convertedJar -and (Test-Path $convertedJar)) { + Remove-Item $convertedJar -Force + } + if (Test-Path $intermediateDir) { + $remaining = Get-ChildItem -Path $intermediateDir -ErrorAction SilentlyContinue + if (-not $remaining) { Remove-Item $intermediateDir -Force -ErrorAction SilentlyContinue } + } + if ($ffStatus -ne 0) { + if ($ffStatus -eq 124) { + Write-Host "Warning: Fernflower/Vineflower exceeded timeout (${ffTimeoutSeconds}s) but wrote $count Java files; treating as partial success." -ForegroundColor Yellow + } else { + Write-Host "Warning: Fernflower/Vineflower exited with status $ffStatus after writing $count Java files; treating as partial success." -ForegroundColor Yellow + } + return 2 + } + return 0 } - # Clean up intermediate dex2jar output - if ($convertedJar -and (Test-Path $convertedJar -ErrorAction SilentlyContinue)) { - Remove-Item $convertedJar -Force + if ($convertedJar -and (Test-Path $convertedJar)) { + Write-Host "Error: Fernflower/Vineflower produced no Java output. Intermediate dex2jar artifact kept at $convertedJar" -ForegroundColor Red + } else { + Write-Host "Error: Fernflower/Vineflower produced no Java output." -ForegroundColor Red + } + if ($ffStatus -eq 124) { + Write-Host "Error: Fernflower/Vineflower exceeded timeout (${ffTimeoutSeconds}s)." -ForegroundColor Red + } elseif ($ffStatus -ne 0) { + Write-Host "Error: Fernflower/Vineflower exited with status $ffStatus." -ForegroundColor Red } - return $true + return 1 } -# --- Summary helper --- function Show-Structure { param([string]$SrcDir, [string]$Label) if (Test-Path $SrcDir) { Write-Host "" Write-Host "Top-level packages ($Label):" - Get-ChildItem -Path $SrcDir -Directory -Recurse -Depth 2 | - Select-Object -First 20 | - ForEach-Object { $_.FullName.Replace("$SrcDir\", '') } | - Sort-Object + $packages = Get-ChildItem -Path $SrcDir -Directory -Recurse -Depth 3 | + ForEach-Object { $_.FullName.Replace("$SrcDir\", '').Replace("$SrcDir/", '') } | + Sort-Object -Unique + if ($packages.Count -eq 0) { + Write-Host "(none)" + } else { + $packages | Select-Object -First 20 | ForEach-Object { Write-Host $_ } + } } } @@ -266,19 +378,33 @@ function Invoke-DecompileSingle { switch ($Engine) { 'jadx' { - Invoke-Jadx -OutDir $OutDir -FileAbs $FileAbs -FileExt $fileExt + $jadxStatus = Invoke-Jadx -OutDir $OutDir -FileAbs $FileAbs -FileExt $fileExt Show-Structure (Join-Path $OutDir 'sources') 'jadx' + if ($jadxStatus -eq 1) { return 1 } + if ($jadxStatus -eq 2) { Write-Host "jadx completed with warnings but produced usable output." } } 'fernflower' { - Invoke-Fernflower -OutDir $OutDir -FileAbs $FileAbs -FileExt $fileExt + $ffBase = [IO.Path]::GetFileNameWithoutExtension($FileAbs) + $ffStatus = Invoke-Fernflower -OutDir $OutDir -FileAbs $FileAbs -FileExt $fileExt -BaseNameForJar $ffBase Show-Structure (Join-Path $OutDir 'sources') 'fernflower' + if ($ffStatus -eq 1) { return 1 } + if ($ffStatus -eq 2) { Write-Host "Fernflower completed with warnings but produced usable output." } } 'both' { Write-Host "--- Pass 1: jadx ---" - Invoke-Jadx -OutDir (Join-Path $OutDir 'jadx') -FileAbs $FileAbs -FileExt $fileExt + $jadxStatus = Invoke-Jadx -OutDir (Join-Path $OutDir 'jadx') -FileAbs $FileAbs -FileExt $fileExt + if ($jadxStatus -eq 1) { return 1 } + if ($jadxStatus -eq 2) { + Write-Host "Continuing to Fernflower because jadx produced usable output despite warnings." + } Write-Host "" Write-Host "--- Pass 2: Fernflower ---" - Invoke-Fernflower -OutDir (Join-Path $OutDir 'fernflower') -FileAbs $FileAbs -FileExt $fileExt + $ffBase = [IO.Path]::GetFileNameWithoutExtension($FileAbs) + $ffStatus = Invoke-Fernflower -OutDir (Join-Path $OutDir 'fernflower') -FileAbs $FileAbs -FileExt $fileExt -BaseNameForJar $ffBase + if ($ffStatus -eq 1) { return 1 } + if ($ffStatus -eq 2) { + Write-Host "Continuing with Fernflower output because it produced usable sources despite warnings." + } Show-Structure (Join-Path $OutDir 'jadx\sources') 'jadx' Show-Structure (Join-Path $OutDir 'fernflower\sources') 'fernflower' @@ -299,7 +425,7 @@ function Invoke-DecompileSingle { if (Test-Path $jadxSources) { $jadxErrors = (Get-ChildItem -Path $jadxSources -Recurse -Filter '*.java' -File | - Select-String -Pattern 'JADX WARNING|JADX WARN|JADX ERROR|Code decompiled incorrectly' -SimpleMatch -ErrorAction SilentlyContinue | + Select-String -Pattern 'JADX WARNING|JADX WARN|JADX ERROR|Code decompiled incorrectly' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Path -Unique).Count Write-Host "jadx files with warnings/errors: $jadxErrors" } @@ -307,6 +433,7 @@ function Invoke-DecompileSingle { Write-Host "Tip: compare specific classes between jadx/ and fernflower/ to pick the better output." } } + return 0 } # --- Run --- diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 index 9084795..0102d1b 100644 --- a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/find-api-calls.ps1 @@ -1,11 +1,14 @@ -# find-api-calls.ps1 — Search decompiled source for API calls and HTTP endpoints +# find-api-calls.ps1: Search decompiled source for API calls and HTTP endpoints param( - [Parameter(Position=0)] + [Parameter(Position = 0)] [string]$SourceDir, [switch]$Retrofit, [switch]$OkHttp, + [switch]$Ktor, + [switch]$Apollo, [switch]$Volley, [switch]$Urls, + [switch]$Paths, [switch]$Auth, [switch]$All, [Alias('h')] @@ -26,8 +29,11 @@ Arguments: Options: -Retrofit Search only for Retrofit annotations -OkHttp Search only for OkHttp patterns + -Ktor Search only for Ktor client patterns + -Apollo Search only for Apollo (GraphQL) patterns -Volley Search only for Volley patterns -Urls Search only for hardcoded URLs + -Paths Extract unique endpoint-shaped path string literals -Auth Search only for auth-related patterns -All Search all patterns (default) -Help Show this help message @@ -39,19 +45,13 @@ Output: } if ($Help) { Show-Usage } - -if (-not $SourceDir) { - Write-Host "Error: No source directory specified." -ForegroundColor Red - Show-Usage -} - -if (-not (Test-Path $SourceDir)) { +if (-not $SourceDir) { Show-Usage } +if (-not (Test-Path $SourceDir -PathType Container)) { Write-Host "Error: Directory not found: $SourceDir" -ForegroundColor Red exit 1 } -# Default to all if no specific flag set -$searchAll = (-not $Retrofit -and -not $OkHttp -and -not $Volley -and -not $Urls -and -not $Auth) -or $All +$searchAll = (-not $Retrofit -and -not $OkHttp -and -not $Ktor -and -not $Apollo -and -not $Volley -and -not $Urls -and -not $Paths -and -not $Auth) -or $All function Write-Section { param([string]$Title) @@ -62,59 +62,188 @@ function Write-Section { function Search-Sources { param([string]$Pattern) - Get-ChildItem -Path $SourceDir -Recurse -Include '*.java','*.kt' -File | + Get-ChildItem -Path $SourceDir -Recurse -Include '*.java', '*.kt' -File | Select-String -Pattern $Pattern -ErrorAction SilentlyContinue | - ForEach-Object { - "$($_.Path):$($_.LineNumber):$($_.Line.Trim())" - } + ForEach-Object { "$($_.Path):$($_.LineNumber):$($_.Line.Trim())" } +} + +function Get-AllSourceLines { + Get-ChildItem -Path $SourceDir -Recurse -Include '*.java', '*.kt' -File | + Select-String -Pattern '.' -ErrorAction SilentlyContinue +} + +if ($searchAll) { + Write-Section "Summary (counted in a single pass)" + $h = @{ + retrofit = 0; okhttp = 0; ktor = 0; apollo = 0; volley = 0 + hilt = 0; koin = 0; bearer = 0; hmac = 0 + } + $summaryPattern = '@(GET|POST|PUT|DELETE|PATCH|HTTP)\(|Request\.Builder|HttpUrl|\.newCall\(|BearerTokens|defaultRequest \{|client\.(get|post)\(|httpClient\.(get|post)\(|ApolloClient|\.serverUrl\(|StringRequest|JsonObjectRequest|RequestQueue|@HiltAndroidApp|@AndroidEntryPoint|@HiltViewModel|@Provides|@Binds|org\.koin\.|module \{|single<|factory<|"[Bb]earer |HmacSHA|Mac\.getInstance' + foreach ($hit in (Get-ChildItem -Path $SourceDir -Recurse -Include '*.java', '*.kt' -File | Select-String -Pattern $summaryPattern -ErrorAction SilentlyContinue)) { + $line = $hit.Line + if ($line -match '@(GET|POST|PUT|DELETE|PATCH|HTTP)\(') { $h.retrofit++ } + if ($line -match 'Request\.Builder|HttpUrl|\.newCall\(') { $h.okhttp++ } + if ($line -match 'BearerTokens|defaultRequest \{|client\.(get|post)\(|httpClient\.(get|post)\(|HttpClient\.get\(') { $h.ktor++ } + if ($line -match 'ApolloClient|\.serverUrl\(') { $h.apollo++ } + if ($line -match 'StringRequest|JsonObjectRequest|RequestQueue') { $h.volley++ } + if ($line -match '@HiltAndroidApp|@AndroidEntryPoint|@HiltViewModel|@Provides|@Binds') { $h.hilt++ } + if ($line -match 'org\.koin\.|module \{|single<|factory<|singleOf\(|factoryOf\(') { $h.koin++ } + if ($line -match '"Bearer |"bearer |BearerTokens') { $h.bearer++ } + if ($line -match 'HmacSHA|Mac\.getInstance\("Hmac') { $h.hmac++ } + } + Write-Host (" HTTP framework: Retrofit={0,-5} OkHttp={1,-5} Ktor={2,-5} Apollo={3,-5} Volley={4,-5}" -f $h.retrofit, $h.okhttp, $h.ktor, $h.apollo, $h.volley) + Write-Host (" DI framework: Hilt/Dagger={0,-5} Koin={1,-5}" -f $h.hilt, $h.koin) + Write-Host (" Auth signals: Bearer={0,-5} HMAC/Sign={1,-5}" -f $h.bearer, $h.hmac) + Write-Host "" + Write-Host " Run with one of -Retrofit / -OkHttp / -Ktor / -Apollo / -Volley /" + Write-Host " -Paths / -Urls / -Auth to inspect a single section." } -# --- Retrofit --- if ($searchAll -or $Retrofit) { Write-Section "Retrofit Annotations" Search-Sources '@(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|HTTP)\s*\(' - Write-Section "Retrofit Headers & Parameters" Search-Sources '@(Headers|Header|Query|QueryMap|Path|Body|Field|FieldMap|Part|PartMap|Url)\s*\(' - Write-Section "Retrofit Base URL" Search-Sources '(baseUrl|base_url)\s*\(' } -# --- OkHttp --- if ($searchAll -or $OkHttp) { Write-Section "OkHttp Request Building" Search-Sources '(Request\.Builder|HttpUrl|\.newCall|\.enqueue|addInterceptor|addNetworkInterceptor)' - Write-Section "OkHttp URL Construction" Search-Sources '(\.url\s*\(|\.addQueryParameter|\.addPathSegment|\.scheme\s*\(|\.host\s*\()' } -# --- Volley --- +if ($searchAll -or $Ktor) { + Write-Section "Ktor: Client Calls" + Search-Sources '\b(client|httpClient|HttpClient)\.(get|post|put|delete|patch|head|request)\s*[<(]' + Write-Section "Ktor: Request Building / Default Request" + Search-Sources '(HttpRequestBuilder|defaultRequest\s*\{|\burl\s*\(\s*"|URLBuilder|URLProtocol)' + Write-Section "Ktor: Auth Plugin (Bearer / Refresh)" + Search-Sources '(\bbearer\s*\{|BearerTokens\s*\(|loadTokens\s*\{|refreshTokens\s*\{|\bAuth\s*\)\s*\{)' +} + +if ($searchAll -or $Apollo) { + Write-Section "Apollo: GraphQL Client" + Search-Sources '(ApolloClient|\.serverUrl\s*\(|\.subscriptionNetworkTransport|HttpNetworkTransport)' + Write-Section "Apollo: Operations" + Search-Sources '(\.query\s*\(\s*[A-Z]|\.mutation\s*\(\s*[A-Z]|\.subscription\s*\(\s*[A-Z])' +} + if ($searchAll -or $Volley) { Write-Section "Volley Requests" Search-Sources '(StringRequest|JsonObjectRequest|JsonArrayRequest|ImageRequest|RequestQueue|Volley\.newRequestQueue)' } -# --- Hardcoded URLs --- +if ($searchAll -or $Paths) { + Write-Section "Endpoint-Shaped Path Literals (deduplicated)" + $seg = '[A-Za-z0-9_{}.\-]+' + $root = '(api|v[0-9]+|graphql|rest|mobile|auth|oauth|sso|users?|account|session|token|register|signup|signin|logout|password|verify|otp|sms|profile|customer|cart|basket|order|checkout|payment|invoice|product|catalog|inventory|search|category|favo[u]?rites?|wishlist|address|location|delivery|shipping|review|feedback|notification|push|message|chat|track|event|stat[a-z]*|metric|config|settings?|feature|flag|banner|content|media|upload|download|file|image|video|live|stream|webhook|callback)' + $pathsRegex = "`"(/$seg(/$seg)+/?|$root(/$seg)+/?)`"" + $exclude = '^(image|video|audio|text|application|content|font|model|multipart|message)/|^/(proc|sys|dev|tmp|etc|usr|var|opt)/' + $allPaths = [System.Collections.Generic.HashSet[string]]::new() + Get-ChildItem -Path $SourceDir -Recurse -Include '*.java', '*.kt' -File | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content) { + [regex]::Matches($content, $pathsRegex) | ForEach-Object { + $val = $_.Value.Trim('"') + if ($val -notmatch $exclude) { [void]$allPaths.Add($_.Value) } + } + } + } + $allPaths | Sort-Object | ForEach-Object { Write-Host $_ } + Write-Host "" + Write-Section "Endpoint-Shaped Path Literals: call sites" + Search-Sources $pathsRegex +} + if ($searchAll -or $Urls) { - Write-Section "Hardcoded URLs (http:// and https://)" - Search-Sources '"https?://[^"]+' + $denylistPath = Join-Path $PSScriptRoot '..\references\third_party_hosts.txt' + $strictUrl = 'https?://(([0-9]{1,3}(\.[0-9]{1,3}){3}|[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})(:[0-9]{1,5})?(/[^"<>[:space:]]*)?|[A-Za-z0-9-]+(:[0-9]{1,5}(/[^"<>[:space:]]*)?|/[^"<>[:space:]]*))' + $validTlds = 'com|net|org|io|co|app|dev|me|ai|xyz|info|biz|gov|edu|mil|int|tech|cloud|uk|de|fr|it|es|nl|in|us|ca|au|jp|cn|br|ru|eu|ch|se|no|fi|dk|pl|pt|gr|ie|be|at|cz|sg|hk|kr|tw|mx|ar|cl|za|nz' + + $urlSet = [System.Collections.Generic.HashSet[string]]::new() + Get-ChildItem -Path $SourceDir -Recurse -Include '*.java', '*.kt' -File | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content) { + [regex]::Matches($content, $strictUrl) | ForEach-Object { + $url = $_.Value + $rest = $url -replace '^https?://', '' + $host = ($rest -split '[/:]')[0] + $hasPathPort = $rest -match '[/:]' + $keep = $false + if ($host -match '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$') { $keep = $true } + elseif (($host -split '\.').Count -ge 3) { $keep = $true } + elseif ($hasPathPort) { $keep = $true } + else { + $parts = $host -split '\.' + if ($parts.Count -eq 2 -and $parts[1] -match "^($validTlds)$") { $keep = $true } + } + if ($keep) { [void]$urlSet.Add($url) } + } + } + } + $urls = $urlSet | Sort-Object + $hosts = $urls | ForEach-Object { ($_ -replace '^https?://', '' -split '[/:]')[0] } | Sort-Object -Unique + + $denyRegex = $null + if (Test-Path $denylistPath) { + $denyLines = Get-Content $denylistPath | Where-Object { $_ -notmatch '^\s*(#|$)' } + if ($denyLines) { $denyRegex = ($denyLines -join '|') } + } + + $firstHosts = @() + $thirdHosts = @() + foreach ($h in $hosts) { + if ($denyRegex -and $h -match $denyRegex) { $thirdHosts += $h } + else { $firstHosts += $h } + } + + Write-Section "Likely First-Party Hosts (frequency-sorted)" + if ($firstHosts.Count) { + $firstHosts | ForEach-Object { + $host = $_ + $n = ($urls | Where-Object { $_ -match "://${([regex]::Escape($host))}([/:`"`]|$)" }).Count + [PSCustomObject]@{ Count = $n; Host = $host } + } | Sort-Object Count -Descending | ForEach-Object { + Write-Host (" {0,5} {1}" -f $_.Count, $_.Host) + } + } else { + Write-Host " (none; every URL matched the third-party denylist)" + } + + Write-Section "Third-Party Hosts (denylist matches, collapsed)" + if ($thirdHosts.Count) { $thirdHosts | ForEach-Object { Write-Host " $_" } } + else { Write-Host " (none)" } + + Write-Section "All First-Party URLs (full strings)" + foreach ($h in $firstHosts) { + $urls | Where-Object { $_ -match "://${([regex]::Escape($h))}([/:`"`]|$)" } | ForEach-Object { Write-Host " $_" } + } Write-Section "HttpURLConnection" Search-Sources '(openConnection|setRequestMethod|HttpURLConnection|HttpsURLConnection)' - Write-Section "WebView URLs" Search-Sources '(loadUrl|loadData|evaluateJavascript|addJavascriptInterface|WebViewClient|WebChromeClient)' } -# --- Auth patterns --- if ($searchAll -or $Auth) { Write-Section "Authentication & API Keys" - Search-Sources '(?i)(api[_\-]?key|auth[_\-]?token|bearer|authorization|x-api-key|client[_\-]?secret|access[_\-]?token)' + Search-Sources '(?i)(api[_\-]?key|auth[_\-]?token|bearer|authorization|x-api-key|client[_\-]?secret|access[_\-]?token|refresh[_\-]?token)' + + Write-Section "Request Signing (HMAC / signature schemes)" + Search-Sources '(HmacSHA(1|256|512)|Mac\.getInstance\("Hmac|SecretKeySpec\(|Signature\.getInstance\()' + Search-Sources '(?i)(x-signature|x-client-authorization|x-amz-signature|x-hmac|aws4-hmac|signRequest|signatureFor|computeSignature|signaturev[0-9])' + + Write-Section "Possible Hardcoded Secrets / Keys" + Search-Sources '(?i)(app[_\-]?secret|client[_\-]?secret|signing[_\-]?key|hmac[_\-]?secret|consumer[_\-]?secret|private[_\-]?key)' Write-Section "Base URLs and Constants" Search-Sources '(?i)(BASE_URL|API_URL|SERVER_URL|ENDPOINT|API_BASE|HOST_NAME)' + + Write-Section "Ktor Auth (Bearer + Refresh)" + Search-Sources '(BearerTokens|loadTokens\s*\{|refreshTokens\s*\{|\bbearer\s*\{)' } Write-Host "" diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.ps1 b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.ps1 new file mode 100644 index 0000000..ce5f653 --- /dev/null +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/fingerprint.ps1 @@ -0,0 +1,274 @@ +# fingerprint.ps1: Triage an APK/XAPK before decompiling (PowerShell port of fingerprint.sh) +param( + [Parameter(Position = 0)] + [string]$Input, + [Alias('h')] + [switch]$Help +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Show-Usage { + Write-Host @" +Usage: fingerprint.ps1 + +Prints a one-screen summary: + * mobile framework (with rationale) + * HTTP / DI / serialization stack hints + * obfuscation indicator + * native libraries (consolidated across split APKs) + * notable third-party SDKs found in assets/ +"@ + exit 0 +} + +if ($Help) { Show-Usage } +if (-not $Input) { Show-Usage } +if (-not (Test-Path $Input)) { + Write-Host "File not found: $Input" -ForegroundColor Red + exit 1 +} + +$ext = [IO.Path]::GetExtension($Input).ToLower() +if ($ext -notin @('.apk', '.xapk', '.apks', '.apkm')) { + Write-Host "Unsupported input: $Input" -ForegroundColor Red + exit 1 +} + +$tmp = Join-Path $env:TEMP "apkfp-$([Guid]::NewGuid().ToString('N').Substring(0, 8))" +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $apks = @() + if ($ext -eq '.apk') { + $apks = @((Resolve-Path $Input).Path) + } else { + $xapkDir = Join-Path $tmp 'xapk' + New-Item -ItemType Directory -Path $xapkDir -Force | Out-Null + [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $Input).Path, $xapkDir) + $apks = Get-ChildItem -Path $xapkDir -Recurse -Filter '*.apk' -File | + Where-Object { + $rel = $_.FullName.Substring($xapkDir.Length).TrimStart('\', '/') + ($rel -split '[\\/]').Count -le 2 + } | + Select-Object -ExpandProperty FullName + } + + if ($apks.Count -eq 0) { + Write-Host "No APK files found in input." -ForegroundColor Red + exit 1 + } + + $listing = [System.Collections.Generic.List[string]]::new() + $dexStrings = [System.Collections.Generic.HashSet[string]]::new() + + function Get-ZipEntries { + param([string]$ZipPath) + try { + $zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) + try { + return $zip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') } + } finally { + $zip.Dispose() + } + } catch { + return @() + } + } + + function Get-DexTypeStrings { + param([byte[]]$Bytes) + $text = [System.Text.Encoding]::ASCII.GetString($Bytes) + $matches = [regex]::Matches($text, 'L[a-z][a-zA-Z0-9_]*(/[a-zA-Z0-9_$]+)+;') + $result = [System.Collections.Generic.HashSet[string]]::new() + foreach ($m in $matches) { + $fqn = $m.Value.TrimStart('L').TrimEnd(';') + if ($fqn.Length -ge 8) { [void]$result.Add($fqn) } + } + return $result + } + + foreach ($apk in $apks) { + $entries = Get-ZipEntries -ZipPath $apk + foreach ($e in $entries) { $listing.Add($e) } + + $dexEntries = $entries | Where-Object { $_ -match '^classes[0-9]*\.dex$' } + foreach ($dexName in $dexEntries) { + try { + $zip = [System.IO.Compression.ZipFile]::OpenRead($apk) + try { + $entry = $zip.GetEntry($dexName) + if (-not $entry) { continue } + $ms = New-Object System.IO.MemoryStream + try { + $stream = $entry.Open() + try { $stream.CopyTo($ms) } finally { $stream.Dispose() } + foreach ($s in (Get-DexTypeStrings -Bytes $ms.ToArray())) { + [void]$dexStrings.Add($s) + } + } finally { + $ms.Dispose() + } + } finally { + $zip.Dispose() + } + } catch { } + } + } + + $listingText = ($listing | Sort-Object -Unique) -join "`n" + $dexText = ($dexStrings | Sort-Object) -join "`n" + + function Test-Has { + param([string]$Pattern) + if ($listingText -match $Pattern) { return $true } + if ($dexText -match $Pattern) { return $true } + return $false + } + + $framework = 'unknown' + $rationale = '' + + if (Test-Has '^lib/[^/]+/libflutter\.so$') { + $framework = 'Flutter' + $rationale = 'lib//libflutter.so present' + if (Test-Has '^lib/[^/]+/libapp\.so$') { $rationale += '; libapp.so contains AOT-compiled Dart' } + } elseif ((Test-Has '^lib/[^/]+/libhermes\.so$') -or (Test-Has '^assets/index\.android\.bundle$') -or (Test-Has '^lib/[^/]+/libreactnativejni\.so$')) { + $framework = 'React Native' + $reasons = @() + if (Test-Has '^lib/[^/]+/libhermes\.so$') { $reasons += 'libhermes.so' } + if (Test-Has '^lib/[^/]+/libreactnativejni\.so$') { $reasons += 'libreactnativejni.so' } + if (Test-Has '^assets/index\.android\.bundle$') { $reasons += 'assets/index.android.bundle' } + $rationale = $reasons -join ' ' + } elseif ((Test-Has '^assets/www/index\.html$') -or (Test-Has '^assets/www/cordova\.js$') -or (Test-Has '^assets/public/index\.html$')) { + $framework = 'Cordova / Capacitor (WebView hybrid)' + $rationale = 'assets/www/ or assets/public/ shell present' + } elseif ((Test-Has '^lib/[^/]+/libmonodroid\.so$') -or (Test-Has '^assemblies/')) { + $framework = 'Xamarin / .NET MAUI' + $rationale = 'libmonodroid.so or assemblies/ present; code is in .NET DLLs' + } elseif (Test-Has '^lib/[^/]+/libmaui\.so$') { + $framework = '.NET MAUI' + $rationale = 'libmaui.so present' + } elseif ((Test-Has '^assets/flutter_assets/') -and -not (Test-Has '^lib/[^/]+/libflutter\.so$')) { + $framework = 'Flutter (code-only split?)' + $rationale = 'flutter_assets/ but no libflutter.so in this APK; check splits' + } else { + if (Test-Has 'androidx\.compose') { + $framework = 'Native Android (Kotlin + Jetpack Compose)' + $rationale = 'androidx.compose.* libraries detected' + } elseif (Test-Has '^META-INF/.*\.kotlin_module$') { + $framework = 'Native Android (Kotlin)' + $rationale = 'kotlin_module metadata present, no Compose markers' + } else { + $framework = 'Native Android (Java/Kotlin)' + $rationale = 'no cross-platform framework markers found' + } + } + + $http = @() + if (Test-Has 'retrofit2') { $http += 'Retrofit' } + if (Test-Has 'okhttp3') { $http += 'OkHttp' } + if (Test-Has 'io/ktor/') { $http += 'Ktor' } + if (Test-Has 'com/apollographql/') { $http += 'Apollo (GraphQL)' } + if (Test-Has 'com/android/volley') { $http += 'Volley' } + + $di = @() + if (Test-Has 'dagger/hilt/') { $di += 'Hilt' } + if (Test-Has '^META-INF/.*dagger.*') { $di += 'Dagger' } + if (Test-Has 'org/koin/') { $di += 'Koin' } + if ($di.Count -eq 0 -and (Test-Has 'javax/inject/')) { $di += 'javax.inject' } + + $ser = @() + if (Test-Has 'kotlinx/serialization/') { $ser += 'kotlinx.serialization' } + if (Test-Has 'com/google/gson/') { $ser += 'Gson' } + if (Test-Has 'com/squareup/moshi/') { $ser += 'Moshi' } + if (Test-Has 'com/fasterxml/jackson/') { $ser += 'Jackson' } + + $shortDirs = ([regex]::Matches($listingText, '(?m)^[a-z]{1,2}/') | ForEach-Object { $_.Value } | Sort-Object -Unique).Count + if ($shortDirs -gt 30) { + $obfuscation = "HIGH ($shortDirs single/double-letter dirs at root)" + } elseif ($shortDirs -gt 10) { + $obfuscation = "MODERATE ($shortDirs short root dirs)" + } else { + $obfuscation = 'LOW (no significant short-name namespace pollution)' + } + + $native = [regex]::Matches($listingText, '(?m)^lib/[^/]+/[^/]+\.so$') | + ForEach-Object { $_.Value } | Sort-Object -Unique + + $sdks = @() + if (Test-Has '^assets/com/appsflyer/') { $sdks += 'AppsFlyer' } + if ((Test-Has 'datadog\.buildId') -or (Test-Has 'com/datadog/')) { $sdks += 'Datadog' } + if (Test-Has 'io/sentry/') { $sdks += 'Sentry' } + if (Test-Has 'com/google/firebase/') { $sdks += 'Firebase' } + if (Test-Has 'com/google/android/gms/') { $sdks += 'Google Play Services' } + if (Test-Has 'com/facebook/') { $sdks += 'Facebook SDK' } + if (Test-Has 'com/payu/') { $sdks += 'PayU' } + if (Test-Has 'com/stripe/') { $sdks += 'Stripe' } + if (Test-Has 'com/braintreepayments/') { $sdks += 'Braintree' } + if (Test-Has 'com/storyteller/') { $sdks += 'Storyteller' } + if (Test-Has 'zendesk/') { $sdks += 'Zendesk' } + if (Test-Has 'com/intercom/') { $sdks += 'Intercom' } + if (Test-Has 'com/segment/analytics') { $sdks += 'Segment' } + if (Test-Has 'com/amplitude/') { $sdks += 'Amplitude' } + if (Test-Has 'com/mixpanel/') { $sdks += 'Mixpanel' } + if (Test-Has 'com/onesignal/') { $sdks += 'OneSignal' } + if (Test-Has 'com/microsoft/clarity') { $sdks += 'Microsoft Clarity' } + if (Test-Has 'com/hotjar/') { $sdks += 'Hotjar' } + if (Test-Has 'com/instabug/') { $sdks += 'Instabug' } + + $buildConfig = if (Test-Has 'BuildConfig\.class$') { + 'present (grep BuildConfig.java after decompile for base URLs / flavor)' + } else { + 'not detected in zip listing (still worth grepping after decompile)' + } + + $baseName = [IO.Path]::GetFileName($Input) + Write-Host "=== APK Fingerprint: $baseName ===" + Write-Host "" + Write-Host "Framework: $framework" + Write-Host " Rationale: $rationale" + Write-Host "Obfuscation: $obfuscation" + Write-Host "" + Write-Host "HTTP stack: $(if ($http.Count) { $http -join ' ' } else { 'none detected' })" + Write-Host "DI: $(if ($di.Count) { $di -join ' ' } else { 'none detected' })" + Write-Host "Serialization: $(if ($ser.Count) { $ser -join ' ' } else { 'none detected' })" + Write-Host "BuildConfig: $buildConfig" + Write-Host "" + Write-Host "Third-party SDKs: $(if ($sdks.Count) { $sdks -join ' ' } else { 'none detected' })" + Write-Host "" + Write-Host "Native libraries (consolidated across splits):" + if ($native.Count) { + $native | ForEach-Object { Write-Host " $_" } + } else { + Write-Host " (none)" + } + Write-Host "" + Write-Host "Recommended next step:" + switch -Regex ($framework) { + '^Flutter' { + Write-Host " Java decompilation will yield ~no app code. The Dart logic lives in" + Write-Host " libapp.so (AOT). Use tools designed for Flutter:" + Write-Host " - reFlutter / Doldrums / blutter (extract Dart class structure)" + Write-Host " - strings/rabin2 on libapp.so for endpoints & string constants" + } + '^React' { + Write-Host " Java code is just the RN host. Real app logic is in JS/Hermes:" + Write-Host " - if Hermes: hbctool disasm assets/index.android.bundle" + Write-Host " - if JSC: js-beautify the bundle and grep for fetch/axios" + } + '^Cordova' { + Write-Host " All app code is in assets/www/ (or assets/public/). Just unzip and" + Write-Host " inspect the HTML/JS; no Java decompile needed." + } + '^(Xamarin|\.NET)' { + Write-Host " App logic is in .NET DLLs (assemblies/). Use ILSpy or dotPeek;" + Write-Host " jadx will only show the Mono host." + } + default { + Write-Host " Proceed with Phase 2: decompile.ps1 " + } + } +} finally { + if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 index 81fab3c..4f81813 100644 --- a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/install-dep.ps1 @@ -1,11 +1,11 @@ -# install-dep.ps1 — Install a single dependency for Android reverse engineering +# install-dep.ps1: Install a single dependency for Android reverse engineering # Usage: install-dep.ps1 # Dependencies: java, jadx, vineflower, dex2jar, apktool, adb # # Exit codes: -# 0 — installed successfully -# 1 — installation failed -# 2 — requires manual action +# 0: installed successfully +# 1: installation failed +# 2: requires manual action param( [Parameter(Position=0)] [string]$Dep @@ -73,11 +73,13 @@ function Get-GHLatestTag { function Add-ToUserPath { param([string]$Dir) $currentPath = [Environment]::GetEnvironmentVariable('PATH', 'User') - if ($currentPath -notlike "*$Dir*") { + $pathEntries = if ($currentPath) { $currentPath -split ';' } else { @() } + if ($pathEntries -notcontains $Dir) { [Environment]::SetEnvironmentVariable('PATH', "$Dir;$currentPath", 'User') Write-Info "Added $Dir to user PATH. Restart your terminal to apply." } - if ($env:PATH -notlike "*$Dir*") { + $sessionEntries = $env:PATH -split ';' + if ($sessionEntries -notcontains $Dir) { $env:PATH = "$Dir;$env:PATH" } } @@ -85,21 +87,30 @@ function Add-ToUserPath { $localBin = Join-Path $env:USERPROFILE '.local\bin' $localShare = Join-Path $env:USERPROFILE '.local\share' +function Get-JavaMajorVersion { + $javaBin = Get-Command java -ErrorAction SilentlyContinue + if (-not $javaBin) { return $null } + $verOutput = & java -version 2>&1 | Select-Object -First 1 + $verStr = "$verOutput" + if ($verStr -match '"(\d+)') { + $ver = [int]$Matches[1] + if ($ver -eq 1 -and $verStr -match '"1\.(\d+)') { + $ver = [int]$Matches[1] + } + return $ver + } + return $null +} + # ===================================================================== # Dependency installers # ===================================================================== function Install-Java { - $javaBin = Get-Command java -ErrorAction SilentlyContinue - if ($javaBin) { - $verOutput = & java -version 2>&1 | Select-Object -First 1 - if ("$verOutput" -match '"(\d+)') { - $ver = [int]$Matches[1] - if ($ver -ge 17) { - Write-Ok "Java $ver already installed" - return - } - } + $ver = Get-JavaMajorVersion + if ($ver -and $ver -ge 17) { + Write-Ok "Java $ver already installed" + return } Write-Info "Installing Java JDK 17+..." @@ -274,15 +285,42 @@ function Install-Dex2Jar { Write-Ok "dex2jar $version installed to $installDir" } +function Install-ApktoolFromGitHub { + $tag = Get-GHLatestTag "iBotPeaches/Apktool" + if (-not $tag) { + Write-Fail "Could not determine latest apktool version." + Write-Manual "Download from https://github.com/iBotPeaches/Apktool/releases/latest" + } + + $version = $tag -replace '^v', '' + $url = "https://github.com/iBotPeaches/Apktool/releases/download/$tag/apktool_$version.jar" + $installDir = Join-Path $localShare 'apktool' + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + + Invoke-Download -Url $url -Dest (Join-Path $installDir 'apktool.jar') + + New-Item -ItemType Directory -Path $localBin -Force | Out-Null + $wrapperPath = Join-Path $localBin 'apktool.cmd' + Set-Content -Path $wrapperPath -Value "@echo off`r`njava -jar -Duser.language=en `"$installDir\apktool.jar`" %*" + + Add-ToUserPath $localBin + Write-Ok "apktool $version installed to $installDir" +} + function Install-Apktool { if (Get-Command apktool -ErrorAction SilentlyContinue) { Write-Ok "apktool already installed" return } + $javaVer = Get-JavaMajorVersion + if ($hasScoop) { Write-Info "Installing apktool via scoop..." scoop install apktool + } elseif ($javaVer -and $javaVer -ge 8) { + Write-Info "Java $javaVer on PATH — installing apktool from GitHub (skipping Chocolatey jre8 dependency)..." + Install-ApktoolFromGitHub } elseif ($hasChoco) { Write-Info "Installing apktool via choco..." choco install apktool -y diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.ps1 b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.ps1 new file mode 100644 index 0000000..dc6cea7 --- /dev/null +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.ps1 @@ -0,0 +1,44 @@ +# lookup-name.ps1: Query the mapping produced by recover-kotlin-names.ps1 +param( + [Parameter(Position = 0)] + [string]$MappingDir, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$QueryArgs, + [Alias('h')] + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +function Show-Usage { + Write-Host @" +Usage: lookup-name.ps1 + lookup-name.ps1 -o + lookup-name.ps1 -p + lookup-name.ps1 --grep +"@ + exit 0 +} + +if ($Help) { Show-Usage } +if (-not $MappingDir -or $QueryArgs.Count -eq 0) { Show-Usage } + +$mapFile = Join-Path $MappingDir 'mapping.json' +if (-not (Test-Path $mapFile)) { + Write-Host "no mapping.json in $MappingDir" -ForegroundColor Red + exit 1 +} + +$pyScript = Join-Path $PSScriptRoot 'lookup_names.py' +$py = Get-Command python -ErrorAction SilentlyContinue +if (-not $py) { $py = Get-Command py -ErrorAction SilentlyContinue } + +$allArgs = @($MappingDir) + $QueryArgs +if ($py -and $py.Name -eq 'python') { + & python $pyScript @allArgs +} elseif ($py) { + & py -3 $pyScript @allArgs +} else { + Write-Host "Error: python not found. Install Python 3." -ForegroundColor Red + exit 1 +} diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.sh b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.sh index 164d558..6cab9b3 100755 --- a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.sh +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup-name.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# lookup-name.sh — Query the mapping produced by recover-kotlin-names.sh. +# lookup-name.sh: Query the mapping produced by recover-kotlin-names.sh. # # Modes: # lookup-name.sh search by real-FQN substring @@ -27,59 +27,5 @@ EOF DIR="$1"; shift [[ ! -f "$DIR/mapping.json" ]] && { echo "no mapping.json in $DIR" >&2; exit 1; } -python3 - "$DIR" "$@" <<'PY' -import json, os, re, sys, subprocess -DIR = sys.argv[1] -args = sys.argv[2:] -MAP = json.load(open(os.path.join(DIR, "mapping.json"))) -REV = {} -for o, r in MAP.items(): - REV.setdefault(r, []).append(o) - -def search(q): - ql = q.lower() - for r in sorted(REV): - if ql in r.lower(): - print(r) - for o in sorted(REV[r]): - print(f" {o}") - -def by_obf(o): - if o not in MAP: - print(f"no mapping for {o}", file=sys.stderr); sys.exit(1) - print(f"{o} -> {MAP[o]}") - sibs = [s for s in REV[MAP[o]] if s != o] - for s in sorted(sibs): - print(f" sibling: {s}") - -def by_pkg(p): - pl = p.lower() - for r in sorted(REV): - if pl in r.rsplit(".", 1)[0].lower(): - print(r) - for o in sorted(REV[r]): - print(f" {o}") - -def grep_annot(pattern, sources): - res = subprocess.run( - ["grep", "-rEn", "--include=*.java", pattern, sources], - capture_output=True, text=True) - for line in res.stdout.splitlines(): - try: - path, lineno, content = line.split(":", 2) - except ValueError: - continue - rel = os.path.relpath(path, sources) - obf = rel.replace(os.sep, ".")[:-5] - suffix = f" // {MAP[obf]}" if obf in MAP else "" - print(f"{rel}:{lineno}:{content}{suffix}") - -if args[0] == "-o" and len(args) == 2: - by_obf(args[1]) -elif args[0] == "-p" and len(args) == 2: - by_pkg(args[1]) -elif args[0] == "--grep" and len(args) == 3: - grep_annot(args[1], args[2]) -else: - search(" ".join(args)) -PY +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 "$SCRIPT_DIR/lookup_names.py" "$DIR" "$@" diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup_names.py b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup_names.py new file mode 100644 index 0000000..ff94de7 --- /dev/null +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/lookup_names.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Query the mapping produced by recover_kotlin_names.py.""" +import json +import os +import subprocess +import sys + + +def main(): + if len(sys.argv) < 3: + print("Usage: lookup_names.py ") + print(" lookup_names.py -o ") + print(" lookup_names.py -p ") + print(" lookup_names.py --grep ") + sys.exit(0) + + dir_path = sys.argv[1] + args = sys.argv[2:] + map_path = os.path.join(dir_path, "mapping.json") + if not os.path.isfile(map_path): + print(f"no mapping.json in {dir_path}", file=sys.stderr) + sys.exit(1) + + with open(map_path) as fh: + mapping = json.load(fh) + + rev = {} + for o, r in mapping.items(): + rev.setdefault(r, []).append(o) + + def search(q): + ql = q.lower() + for r in sorted(rev): + if ql in r.lower(): + print(r) + for o in sorted(rev[r]): + print(f" {o}") + + def by_obf(o): + if o not in mapping: + print(f"no mapping for {o}", file=sys.stderr) + sys.exit(1) + print(f"{o} -> {mapping[o]}") + for s in sorted(rev[mapping[o]]): + if s != o: + print(f" sibling: {s}") + + def by_pkg(p): + pl = p.lower() + for r in sorted(rev): + if pl in r.rsplit(".", 1)[0].lower(): + print(r) + for o in sorted(rev[r]): + print(f" {o}") + + def grep_annot(pattern, sources): + if sys.platform == "win32": + cmd = [ + "powershell", "-NoProfile", "-Command", + f"Get-ChildItem -Path '{sources}' -Recurse -Include *.java -File | " + f"Select-String -Pattern '{pattern}' | " + "ForEach-Object {{ $_.Path + ':' + $_.LineNumber + ':' + $_.Line.Trim() }}" + ] + res = subprocess.run(cmd, capture_output=True, text=True) + lines = res.stdout.splitlines() + else: + res = subprocess.run( + ["grep", "-rEn", "--include=*.java", pattern, sources], + capture_output=True, text=True) + lines = res.stdout.splitlines() + + for line in lines: + try: + path, lineno, content = line.split(":", 2) + except ValueError: + continue + rel = os.path.relpath(path, sources) + obf = rel.replace(os.sep, ".")[:-5] + suffix = f" // {mapping[obf]}" if obf in mapping else "" + print(f"{rel}:{lineno}:{content}{suffix}") + + if args[0] == "-o" and len(args) == 2: + by_obf(args[1]) + elif args[0] == "-p" and len(args) == 2: + by_pkg(args[1]) + elif args[0] == "--grep" and len(args) == 3: + grep_annot(args[1], args[2]) + else: + search(" ".join(args)) + + +if __name__ == "__main__": + main() diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.ps1 b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.ps1 new file mode 100644 index 0000000..cc43258 --- /dev/null +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.ps1 @@ -0,0 +1,41 @@ +# recover-kotlin-names.ps1: Rebuild obfuscated -> real Kotlin class-name map +param( + [Parameter(Position = 0)] + [string]$SourceDir, + [Parameter(Position = 1)] + [string]$OutputDir, + [Alias('h')] + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +function Show-Usage { + Write-Host @" +Usage: recover-kotlin-names.ps1 [output-dir] + +Walks every *.java under , mines @DebugMetadata +and @Metadata annotations, and writes mapping.tsv, mapping.json, by_package/. +"@ + exit 0 +} + +if ($Help) { Show-Usage } +if (-not $SourceDir) { Show-Usage } +if (-not (Test-Path $SourceDir -PathType Container)) { + Write-Host "not a directory: $SourceDir" -ForegroundColor Red + exit 1 +} + +$pyScript = Join-Path $PSScriptRoot 'recover_kotlin_names.py' +$py = Get-Command python -ErrorAction SilentlyContinue +if (-not $py) { $py = Get-Command py -ErrorAction SilentlyContinue } + +if ($py -and $py.Name -eq 'python') { + if ($OutputDir) { & python $pyScript $SourceDir $OutputDir } else { & python $pyScript $SourceDir } +} elseif ($py) { + if ($OutputDir) { & py -3 $pyScript $SourceDir $OutputDir } else { & py -3 $pyScript $SourceDir } +} else { + Write-Host "Error: python not found. Install Python 3." -ForegroundColor Red + exit 1 +} diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.sh b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.sh index e71eb2a..9ce4f94 100755 --- a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.sh +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover-kotlin-names.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# recover-kotlin-names.sh — Rebuild a (obfuscated -> real) class-name map +# recover-kotlin-names.sh: Rebuild a (obfuscated -> real) class-name map # from Kotlin metadata strings left in decompiled sources. # -# R8 obfuscates JVM symbols but cannot strip the Kotlin metadata strings — +# R8 obfuscates JVM symbols but cannot strip the Kotlin metadata strings; # the Kotlin runtime (reflection, coroutines) needs them at runtime. Two # annotations carry the original FQN: # @@ -14,7 +14,7 @@ # class refs of the file. # # Typical recovery on a real-world app: 30-50 % of classes regain their real -# names — usually 100 % of the *Repository / *ViewModel / *UseCase / *Impl +# names; usually 100 % of the *Repository / *ViewModel / *UseCase / *Impl # classes you actually want to read. set -euo pipefail @@ -43,98 +43,5 @@ OUT="${2:-$(dirname "$SRC")/mapping}" mkdir -p "$OUT/by_package" -python3 - "$SRC" "$OUT" <<'PY' -import os, re, sys, json -from collections import defaultdict - -SRC, OUT = sys.argv[1], sys.argv[2] - -# @DebugMetadata(c = "com.foo.Bar$Inner$1", ...) -RE_DEBUG = re.compile(r'@DebugMetadata\([^)]*?c\s*=\s*"([^"]+)"', re.S) -# @Metadata(... d2 = { "...Lcom/foo/Bar;..." ...} ) -RE_DTWO = re.compile(r'@Metadata\([^)]*?d2\s*=\s*\{([^}]*)\}', re.S) -RE_LCLASS = re.compile(r'L([A-Za-z][\w/$]+);') -# jadx sometimes emits this comment for renamed classes -RE_RENAMED = re.compile(r'/\*\s*renamed from:\s*([\w.$]+)\s*\*/') - -# Skip third-party / framework trees — their names are already real. -SKIP_PREFIXES = ( - "kotlin.", "kotlinx.", "androidx.", "android.", "java.", "javax.", - "com.google.", "com.facebook.", "com.appsflyer.", "com.datadog.", - "io.ktor.", "io.sentry.", "io.realm.", "okhttp3.", "okio.", - "com.squareup.", "com.bumptech.", "com.airbnb.", "com.payu.", - "com.storyteller.", "zendesk.", "io.intercom.", "com.microsoft.", - "com.tinder.", "com.hotjar.", "com.amplitude.", "com.segment.", - "com.mixpanel.", "com.onesignal.", "com.stripe.", "com.braintreepayments.", - "retrofit2.", "dagger.", "javax.inject.", "org.jetbrains.", -) - -mapping = {} -file_real = {} -counts = defaultdict(int) - -for dp, _, files in os.walk(SRC): - for f in files: - if not f.endswith(".java"): - continue - path = os.path.join(dp, f) - rel = os.path.relpath(path, SRC) - obf = rel[:-5].replace(os.sep, ".") - if obf.startswith(SKIP_PREFIXES): - continue - try: - text = open(path, "r", errors="replace").read() - except OSError: - continue - real = None - - m = RE_DEBUG.search(text) - if m: - real = m.group(1).split("$", 1)[0] - counts["debug_meta"] += 1 - - if not real: - m = RE_DTWO.search(text) - if m: - for lm in RE_LCLASS.finditer(m.group(1)): - cand = lm.group(1).replace("/", ".").split("$", 1)[0] - if "." in cand and not cand.startswith(("kotlin.", "java.", "android")): - real = cand - counts["d2"] += 1 - break - - if not real: - m = RE_RENAMED.search(text) - if m: - real = m.group(1) - counts["renamed"] += 1 - - if real: - mapping[obf] = real - file_real[obf] = path - -with open(os.path.join(OUT, "mapping.tsv"), "w") as f: - f.write("obf_fqn\treal_fqn\tfile\n") - for k in sorted(mapping): - f.write(f"{k}\t{mapping[k]}\t{file_real[k]}\n") - -with open(os.path.join(OUT, "mapping.json"), "w") as f: - json.dump(mapping, f, indent=2, sort_keys=True) - -by_pkg = defaultdict(list) -for obf, real in mapping.items(): - pkg = real.rsplit(".", 1)[0] if "." in real else "(default)" - by_pkg[pkg].append((real, obf, file_real[obf])) - -for pkg, rows in by_pkg.items(): - safe = os.path.basename(pkg).replace(".", "_") or "default" - with open(os.path.join(OUT, "by_package", f"{safe}.txt"), "w") as f: - for real, obf, p in sorted(rows): - f.write(f"{real}\t{obf}\t{p}\n") - -print(f"Recovered {len(mapping)} class names") -for k, v in counts.items(): - print(f" via {k}: {v}") -print(f"Real packages: {len(by_pkg)}") -print(f"Wrote {OUT}/mapping.tsv, mapping.json, by_package/") -PY +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 "$SCRIPT_DIR/recover_kotlin_names.py" "$SRC" "$OUT" diff --git a/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover_kotlin_names.py b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover_kotlin_names.py new file mode 100644 index 0000000..4d56397 --- /dev/null +++ b/plugins/android-reverse-engineering/skills/android-reverse-engineering/scripts/recover_kotlin_names.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Rebuild obfuscated -> real Kotlin class-name map from decompiled sources.""" +import json +import os +import re +import sys +from collections import defaultdict + +RE_DEBUG = re.compile(r'@DebugMetadata\([^)]*?c\s*=\s*"([^"]+)"', re.S) +RE_DTWO = re.compile(r'@Metadata\([^)]*?d2\s*=\s*\{([^}]*)\}', re.S) +RE_LCLASS = re.compile(r'L([A-Za-z][\w/$]+);') +RE_RENAMED = re.compile(r'/\*\s*renamed from:\s*([\w.$]+)\s*\*/') + +SKIP_PREFIXES = ( + "kotlin.", "kotlinx.", "androidx.", "android.", "java.", "javax.", + "com.google.", "com.facebook.", "com.appsflyer.", "com.datadog.", + "io.ktor.", "io.sentry.", "io.realm.", "okhttp3.", "okio.", + "com.squareup.", "com.bumptech.", "com.airbnb.", "com.payu.", + "com.storyteller.", "zendesk.", "io.intercom.", "com.microsoft.", + "com.tinder.", "com.hotjar.", "com.amplitude.", "com.segment.", + "com.mixpanel.", "com.onesignal.", "com.stripe.", "com.braintreepayments.", + "retrofit2.", "dagger.", "javax.inject.", "org.jetbrains.", +) + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): + print("Usage: recover_kotlin_names.py [output-dir]") + sys.exit(0) + + src = sys.argv[1] + out = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(src), "mapping") + + if not os.path.isdir(src): + print(f"not a directory: {src}", file=sys.stderr) + sys.exit(1) + + os.makedirs(os.path.join(out, "by_package"), exist_ok=True) + + mapping = {} + file_real = {} + counts = defaultdict(int) + + for dp, _, files in os.walk(src): + for f in files: + if not f.endswith(".java"): + continue + path = os.path.join(dp, f) + rel = os.path.relpath(path, src) + obf = rel[:-5].replace(os.sep, ".") + if obf.startswith(SKIP_PREFIXES): + continue + try: + with open(path, "r", errors="replace") as fh: + text = fh.read() + except OSError: + continue + real = None + + m = RE_DEBUG.search(text) + if m: + real = m.group(1).split("$", 1)[0] + counts["debug_meta"] += 1 + + if not real: + m = RE_DTWO.search(text) + if m: + for lm in RE_LCLASS.finditer(m.group(1)): + cand = lm.group(1).replace("/", ".").split("$", 1)[0] + if "." in cand and not cand.startswith(("kotlin.", "java.", "android")): + real = cand + counts["d2"] += 1 + break + + if not real: + m = RE_RENAMED.search(text) + if m: + real = m.group(1) + counts["renamed"] += 1 + + if real: + mapping[obf] = real + file_real[obf] = path + + with open(os.path.join(out, "mapping.tsv"), "w") as fh: + fh.write("obf_fqn\treal_fqn\tfile\n") + for k in sorted(mapping): + fh.write(f"{k}\t{mapping[k]}\t{file_real[k]}\n") + + with open(os.path.join(out, "mapping.json"), "w") as fh: + json.dump(mapping, fh, indent=2, sort_keys=True) + + by_pkg = defaultdict(list) + for obf, real in mapping.items(): + pkg = real.rsplit(".", 1)[0] if "." in real else "(default)" + by_pkg[pkg].append((real, obf, file_real[obf])) + + for pkg, rows in by_pkg.items(): + safe = os.path.basename(pkg).replace(".", "_") or "default" + with open(os.path.join(out, "by_package", f"{safe}.txt"), "w") as fh: + for real, obf, p in sorted(rows): + fh.write(f"{real}\t{obf}\t{p}\n") + + print(f"Recovered {len(mapping)} class names") + for k, v in counts.items(): + print(f" via {k}: {v}") + print(f"Real packages: {len(by_pkg)}") + print(f"Wrote {out}/mapping.tsv, mapping.json, by_package/") + + +if __name__ == "__main__": + main()