The anything-analyzer start path backgrounds `nohup pnpm dev`. On Ubuntu CI that never invoked the pnpm stub, so the source-pin regression missed the argv. Intercept nohup like sudo, and fall back to a plain background when nohup is absent.
1010 lines
30 KiB
Bash
1010 lines
30 KiB
Bash
#!/usr/bin/env bash
|
|
# bootstrap-reverse.sh — generic Linux/macOS bootstrapper
|
|
#
|
|
# Parity target: skills/scripts/bootstrap-reverse.ps1
|
|
# Supports the same capability names and the same high-level modes:
|
|
# - dependency expansion
|
|
# - package / release / pipx / npm installation
|
|
# - MCP registration hints / config writing
|
|
# - optional service start with --start-services
|
|
# - refresh tool index unless --skip-refresh
|
|
#
|
|
# Usage:
|
|
# bash skills/scripts/bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh]
|
|
# bash skills/scripts/bootstrap-reverse.sh --list
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
REPO_ROOT="$(cd "$SKILL_ROOT/.." && pwd)"
|
|
TOOLS_ROOT="${REVERSE_SKILL_TOOLS_DIR:-$HOME/tools}"
|
|
if [[ "$TOOLS_ROOT" != /* ]]; then
|
|
TOOLS_ROOT="$PWD/$TOOLS_ROOT"
|
|
fi
|
|
if [[ -z "$TOOLS_ROOT" || "$TOOLS_ROOT" == "/" || "$TOOLS_ROOT" == "$HOME" ]]; then
|
|
echo "Unsafe REVERSE_SKILL_TOOLS_DIR: $TOOLS_ROOT" >&2
|
|
exit 2
|
|
fi
|
|
MCP_CONFIG_PATH="${CLAUDE_MCP_CONFIG:-$HOME/.claude/mcp.json}"
|
|
MANIFEST_PATH="$SCRIPT_DIR/bootstrap-manifest.json"
|
|
|
|
UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
|
|
case "$UNAME_S" in
|
|
Darwin) PLATFORM="macos" ;;
|
|
Linux) PLATFORM="linux" ;;
|
|
*) PLATFORM="unknown" ;;
|
|
esac
|
|
|
|
START_SERVICES=false
|
|
SKIP_REFRESH=false
|
|
LIST_ONLY=false
|
|
MANUAL_REQUIRED=false
|
|
FAILED=false
|
|
LAST_CAPABILITY_MANUAL=false
|
|
CAPABILITIES=()
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--start-services) START_SERVICES=true ;;
|
|
--skip-refresh) SKIP_REFRESH=true ;;
|
|
--list|-l) LIST_ONLY=true ;;
|
|
--help|-h) CAPABILITIES+=("__help__") ;;
|
|
-*) echo "Unknown option: $arg" >&2; exit 2 ;;
|
|
*) CAPABILITIES+=("$arg") ;;
|
|
esac
|
|
done
|
|
|
|
log_info() { printf '\033[36m[INFO]\033[0m %s\n' "$*"; }
|
|
log_ok() { printf '\033[32m[OK]\033[0m %s\n' "$*"; }
|
|
log_warn() { printf '\033[33m[WARN]\033[0m %s\n' "$*"; }
|
|
log_err() { printf '\033[31m[ERR]\033[0m %s\n' "$*"; }
|
|
|
|
has_cmd() { command -v "$1" >/dev/null 2>&1; }
|
|
cmd_path() { command -v "$1" 2>/dev/null || true; }
|
|
|
|
json_escape() {
|
|
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
|
|
}
|
|
|
|
ensure_dir() { mkdir -p "$1"; }
|
|
|
|
manifest_field() {
|
|
local capability="$1"
|
|
local field="$2"
|
|
python3 - "$MANIFEST_PATH" "$capability" "$field" <<'PY'
|
|
import json, pathlib, sys
|
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8'))
|
|
for capability in manifest.get('capabilities', []):
|
|
if capability.get('name') == sys.argv[2]:
|
|
value = capability.get(sys.argv[3])
|
|
if value is None or value == '':
|
|
raise SystemExit(1)
|
|
if isinstance(value, (dict, list)):
|
|
print(json.dumps(value, separators=(',', ':')))
|
|
else:
|
|
print(value)
|
|
raise SystemExit(0)
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
manifest_dependency() {
|
|
local name="$1"
|
|
local field="$2"
|
|
python3 - "$MANIFEST_PATH" "$name" "$field" <<'PY'
|
|
import json, pathlib, sys
|
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8'))
|
|
value = manifest.get('bootstrapDependencies', {}).get(sys.argv[2], {}).get(sys.argv[3])
|
|
if value is None or value == '':
|
|
raise SystemExit(1)
|
|
print(value)
|
|
PY
|
|
}
|
|
|
|
safe_remove_install_dir() {
|
|
local target="$1"
|
|
local tmp_target="${2:-}"
|
|
if [[ -z "$target" || "$target" == "/" || "$target" == "$HOME" || "$target" == "$TOOLS_ROOT" ]]; then
|
|
log_err "Refusing to remove unsafe install path: $target"
|
|
return 1
|
|
fi
|
|
case "$target" in
|
|
"$TOOLS_ROOT"/*) ;;
|
|
*) log_err "Refusing to remove path outside tools root: $target"; return 1 ;;
|
|
esac
|
|
rm -rf "$target"
|
|
if [[ -n "$tmp_target" ]]; then
|
|
case "$tmp_target" in
|
|
/tmp/reverse-bootstrap-*|"$TOOLS_ROOT"/*.tmp) rm -rf "$tmp_target" ;;
|
|
*) log_err "Refusing to remove unsafe tmp path: $tmp_target"; return 1 ;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
make_temp_file() {
|
|
local suffix="${1:-download}"
|
|
local tmp_dir
|
|
tmp_dir="$(mktemp -d /tmp/reverse-bootstrap-XXXXXX)"
|
|
printf '%s/%s
|
|
' "$tmp_dir" "$suffix"
|
|
}
|
|
|
|
sudo_cmd() {
|
|
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
|
|
"$@"
|
|
elif has_cmd sudo; then
|
|
sudo "$@"
|
|
else
|
|
log_err "sudo is required for: $*"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
is_kali() {
|
|
[[ -f /etc/os-release ]] || return 1
|
|
local line
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
case "$line" in
|
|
ID_LIKE=*|id_like=*) continue ;;
|
|
ID=*|id=*)
|
|
case "$line" in
|
|
*[Kk][Aa][Ll][Ii]*) return 0 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
done < /etc/os-release
|
|
return 1
|
|
}
|
|
|
|
platform_doc() {
|
|
case "$PLATFORM" in
|
|
macos) echo "docs/platforms/macos.md" ;;
|
|
linux)
|
|
if is_kali; then echo "kali/README-kali.md"; else echo "docs/platforms/linux.md"; fi
|
|
;;
|
|
*) echo "PLATFORMS.md" ;;
|
|
esac
|
|
}
|
|
|
|
print_usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
bash skills/scripts/bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh]
|
|
bash skills/scripts/bootstrap-reverse.sh --list
|
|
|
|
Capabilities (parity with bootstrap-reverse.ps1):
|
|
jadx apktool frida frida-ps idalib-mcp jshookmcp reqable-mcp anything-analyzer idapro
|
|
r2 rabin2 adb agent-browser ghidra-mcp seclists proxycat burpsuite-mcp
|
|
nmap pentestswarm binwalk yara pwntools
|
|
|
|
Examples:
|
|
bash skills/scripts/bootstrap-reverse.sh jadx apktool frida
|
|
bash skills/scripts/bootstrap-reverse.sh jshookmcp reqable-mcp
|
|
bash skills/scripts/bootstrap-reverse.sh idapro --start-services
|
|
bash skills/scripts/bootstrap-reverse.sh burpsuite-mcp
|
|
|
|
Notes:
|
|
- This script supports Linux and macOS.
|
|
- It writes MCP config to ~/.claude/mcp.json by default.
|
|
- Override with CLAUDE_MCP_CONFIG=/path/to/mcp.json.
|
|
- Override install root with REVERSE_SKILL_TOOLS_DIR=~/tools.
|
|
EOF
|
|
}
|
|
|
|
ALL_CAPABILITIES=(
|
|
jadx apktool jeb-pro frida frida-ps idalib-mcp jshookmcp reqable-mcp anything-analyzer idapro
|
|
r2 rabin2 adb agent-browser ghidra-mcp seclists proxycat burpsuite-mcp
|
|
nmap pentestswarm binwalk yara pwntools
|
|
)
|
|
|
|
if $LIST_ONLY; then
|
|
printf '%s\n' "${ALL_CAPABILITIES[@]}"
|
|
exit 0
|
|
fi
|
|
|
|
if [[ ${#CAPABILITIES[@]} -eq 0 || "${CAPABILITIES[0]}" == "__help__" ]]; then
|
|
print_usage
|
|
exit 0
|
|
fi
|
|
|
|
install_apt() {
|
|
local package="$1"
|
|
log_info "apt install $package"
|
|
sudo_cmd apt-get update -qq
|
|
sudo_cmd apt-get install -y "$package"
|
|
}
|
|
|
|
install_brew() {
|
|
local package="$1"
|
|
if ! has_cmd brew; then
|
|
log_err "Homebrew is required. Install it first: https://brew.sh/"
|
|
return 1
|
|
fi
|
|
log_info "brew install $package"
|
|
brew install "$package"
|
|
}
|
|
|
|
install_brew_cask() {
|
|
local package="$1"
|
|
if ! has_cmd brew; then
|
|
log_err "Homebrew is required. Install it first: https://brew.sh/"
|
|
return 1
|
|
fi
|
|
log_info "brew install --cask $package"
|
|
brew install --cask "$package"
|
|
}
|
|
|
|
ensure_python_runtime() {
|
|
ensure_python_interpreter || return 1
|
|
local pipx_package pipx_version current_version
|
|
pipx_package=$(manifest_dependency pipx package) || return 1
|
|
pipx_version=$(manifest_dependency pipx version) || return 1
|
|
current_version=""
|
|
if has_cmd pipx; then
|
|
current_version=$(pipx --version 2>/dev/null | head -n1 | tr -d '[:space:]')
|
|
fi
|
|
if [[ "$current_version" != "$pipx_version" ]]; then
|
|
python3 -m pip install --user --upgrade "$pipx_package" || return 1
|
|
fi
|
|
python3 -m pipx ensurepath >/dev/null 2>&1 || true
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
}
|
|
|
|
ensure_python_interpreter() {
|
|
if ! has_cmd python3; then
|
|
case "$PLATFORM" in
|
|
macos) install_brew python ;;
|
|
linux) install_apt python3 ;;
|
|
*) log_err "Install Python 3 manually. See $(platform_doc)"; return 1 ;;
|
|
esac
|
|
fi
|
|
has_cmd python3 || { log_err "Python 3 installation completed without a usable python3 command."; return 1; }
|
|
}
|
|
|
|
ensure_node_runtime() {
|
|
if has_cmd node && has_cmd npm && has_cmd npx; then return 0; fi
|
|
case "$PLATFORM" in
|
|
macos) install_brew node ;;
|
|
linux) install_apt nodejs; install_apt npm ;;
|
|
*) log_err "Install Node.js manually. See $(platform_doc)"; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
ensure_java_runtime() {
|
|
if has_cmd java; then return 0; fi
|
|
case "$PLATFORM" in
|
|
macos) install_brew openjdk ;;
|
|
linux) install_apt openjdk-17-jdk ;;
|
|
*) log_err "Install Java manually. See $(platform_doc)"; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
ensure_pnpm() {
|
|
ensure_node_runtime || return 1
|
|
local package version current_version
|
|
package=$(manifest_dependency pnpm package) || return 1
|
|
version=$(manifest_dependency pnpm version) || return 1
|
|
current_version=""
|
|
if has_cmd pnpm; then
|
|
current_version=$(pnpm --version 2>/dev/null | head -n1 | tr -d '[:space:]')
|
|
fi
|
|
if [[ "$current_version" != "$version" ]]; then
|
|
npm install -g "$package" || return 1
|
|
fi
|
|
}
|
|
|
|
# Args: repo regex [release_tag]
|
|
# Prints: url\tdigest_or_empty
|
|
latest_github_asset_meta() {
|
|
local repo="$1"
|
|
local regex="$2"
|
|
local tag="${3:-}"
|
|
python3 - "$repo" "$regex" "$tag" <<'PY'
|
|
import json, re, sys, urllib.request
|
|
repo, pattern, tag = sys.argv[1:4]
|
|
if tag:
|
|
url = f'https://api.github.com/repos/{repo}/releases/tags/{tag}'
|
|
else:
|
|
url = f'https://api.github.com/repos/{repo}/releases/latest'
|
|
req = urllib.request.Request(url, headers={'User-Agent':'reverse-skill-bootstrap'})
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
data = json.load(r)
|
|
for asset in data.get('assets', []):
|
|
if re.search(pattern, asset.get('name','')):
|
|
digest = asset.get('digest') or ''
|
|
print(f"{asset.get('browser_download_url')}\t{digest}")
|
|
raise SystemExit(0)
|
|
raise SystemExit(f'no asset matched {pattern} for {repo} tag={tag!r}')
|
|
PY
|
|
}
|
|
|
|
latest_github_asset_url() {
|
|
local repo="$1"
|
|
local regex="$2"
|
|
local tag="${3:-}"
|
|
latest_github_asset_meta "$repo" "$regex" "$tag" | cut -f1
|
|
}
|
|
|
|
# verify_sha256 file expected_or_empty [github_digest]
|
|
# expected may be "hex" or "sha256:hex"; github_digest same. Prefer expected.
|
|
verify_sha256() {
|
|
local file="$1"
|
|
local expected="$2"
|
|
local api_digest="${3:-}"
|
|
local actual
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
actual=$(sha256sum "$file" | awk '{print tolower($1)}')
|
|
elif command -v shasum >/dev/null 2>&1; then
|
|
actual=$(shasum -a 256 "$file" | awk '{print tolower($1)}')
|
|
else
|
|
log_warn "sha256 tool missing; skip integrity for $file"
|
|
return 0
|
|
fi
|
|
local exp="$expected"
|
|
if [[ -z "$exp" && -n "$api_digest" ]]; then
|
|
exp="$api_digest"
|
|
fi
|
|
exp=$(printf '%s' "$exp" | sed -E 's/^[Ss][Hh][Aa]256://')
|
|
exp=$(printf '%s' "$exp" | tr 'A-F' 'a-f')
|
|
if [[ -n "$exp" ]]; then
|
|
if [[ "$actual" != "$exp" ]]; then
|
|
rm -f "$file"
|
|
log_err "SHA256 mismatch for $(basename "$file"): expected $exp got $actual (file deleted)"
|
|
return 1
|
|
fi
|
|
log_ok "SHA256 OK: $actual"
|
|
else
|
|
log_warn "No pinned digest for $(basename "$file"); recorded sha256=$actual"
|
|
fi
|
|
}
|
|
|
|
extract_archive() {
|
|
local archive="$1"
|
|
local dest="$2"
|
|
safe_remove_install_dir "$dest" "$dest.tmp"
|
|
mkdir -p "$dest"
|
|
case "$archive" in
|
|
*.zip)
|
|
unzip -q "$archive" -d "$dest.tmp"
|
|
;;
|
|
*.tar.gz|*.tgz)
|
|
mkdir -p "$dest.tmp"
|
|
tar -xzf "$archive" -C "$dest.tmp"
|
|
;;
|
|
*)
|
|
mkdir -p "$dest"
|
|
cp "$archive" "$dest/"
|
|
return 0
|
|
;;
|
|
esac
|
|
local top_count
|
|
top_count=$(find "$dest.tmp" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
|
|
if [[ "$top_count" == "1" ]] && [[ -d "$(find "$dest.tmp" -mindepth 1 -maxdepth 1 | head -n1)" ]]; then
|
|
cp -a "$(find "$dest.tmp" -mindepth 1 -maxdepth 1 | head -n1)"/. "$dest/"
|
|
else
|
|
cp -a "$dest.tmp"/. "$dest/"
|
|
fi
|
|
case "$dest.tmp" in /tmp/reverse-bootstrap-*|"$TOOLS_ROOT"/*.tmp) rm -rf "$dest.tmp" ;; esac
|
|
}
|
|
|
|
# install_github_release repo regex dest [release_tag] [expected_sha256]
|
|
install_github_release() {
|
|
local repo="$1"
|
|
local regex="$2"
|
|
local dest="$3"
|
|
local tag="${4:-}"
|
|
local expected_sha="${5:-}"
|
|
local meta url digest file
|
|
ensure_dir "$TOOLS_ROOT"
|
|
meta=$(latest_github_asset_meta "$repo" "$regex" "$tag")
|
|
url=$(printf '%s' "$meta" | cut -f1)
|
|
digest=$(printf '%s' "$meta" | cut -f2)
|
|
file="$(make_temp_file "$(basename "$url")")"
|
|
log_info "download $url"
|
|
curl -L -o "$file" "$url"
|
|
verify_sha256 "$file" "$expected_sha" "$digest" || return 1
|
|
extract_archive "$file" "$dest"
|
|
rm -rf "$(dirname "$file")"
|
|
export PATH="$dest/bin:$dest:$PATH"
|
|
log_ok "installed $repo to $dest"
|
|
}
|
|
|
|
install_git_commit() {
|
|
local repo="$1"
|
|
local commit="$2"
|
|
local install_dir="$3"
|
|
|
|
git_checkout_is_clean() {
|
|
local checkout="$1"
|
|
local status
|
|
if ! status=$(git -C "$checkout" status --porcelain --untracked-files=all); then
|
|
log_err "Cannot inspect checkout state: $checkout"
|
|
return 1
|
|
fi
|
|
if [[ -n "$status" ]]; then
|
|
log_err "Existing checkout has local changes; refusing to execute it: $checkout"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
cleanup_git_stage() {
|
|
local stage="$1"
|
|
local parent="$2"
|
|
case "$stage" in
|
|
"$parent"/.reverse-bootstrap-*) rm -rf "$stage" ;;
|
|
*) log_err "Refusing to clean unexpected staging path: $stage" ;;
|
|
esac
|
|
}
|
|
|
|
if [[ -d "$install_dir/.git" ]]; then
|
|
local current
|
|
if ! current=$(git -C "$install_dir" rev-parse HEAD 2>/dev/null); then
|
|
log_err "Cannot resolve existing checkout HEAD: $install_dir"
|
|
return 1
|
|
fi
|
|
if [[ "$current" != "$commit" ]]; then
|
|
log_err "Existing checkout is not at pinned commit $commit: $install_dir"
|
|
log_err "Move it aside explicitly, then retry; bootstrap will not overwrite local changes."
|
|
return 1
|
|
fi
|
|
git_checkout_is_clean "$install_dir" || return 1
|
|
return 0
|
|
fi
|
|
if [[ -e "$install_dir" ]]; then
|
|
log_err "Install path exists but is not a git checkout: $install_dir"
|
|
return 1
|
|
fi
|
|
|
|
local parent stage resolved
|
|
parent=$(dirname "$install_dir")
|
|
ensure_dir "$parent"
|
|
stage=$(mktemp -d "$parent/.reverse-bootstrap-XXXXXX") || return 1
|
|
if ! git init --quiet "$stage" ||
|
|
! git -C "$stage" remote add origin "$repo" ||
|
|
! git -C "$stage" fetch --depth 1 origin "$commit" ||
|
|
! git -C "$stage" checkout --quiet --detach FETCH_HEAD; then
|
|
cleanup_git_stage "$stage" "$parent"
|
|
return 1
|
|
fi
|
|
if ! resolved=$(git -C "$stage" rev-parse HEAD); then
|
|
cleanup_git_stage "$stage" "$parent"
|
|
return 1
|
|
fi
|
|
if [[ "$resolved" != "$commit" ]]; then
|
|
log_err "Pinned checkout verification failed for $repo: expected $commit, got $resolved"
|
|
cleanup_git_stage "$stage" "$parent"
|
|
return 1
|
|
fi
|
|
if ! git_checkout_is_clean "$stage"; then
|
|
cleanup_git_stage "$stage" "$parent"
|
|
return 1
|
|
fi
|
|
if ! python3 - "$stage" "$install_dir" <<'PY'
|
|
import os, sys
|
|
os.rename(sys.argv[1], sys.argv[2])
|
|
PY
|
|
then
|
|
cleanup_git_stage "$stage" "$parent"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
write_mcp_server() {
|
|
local name="$1"
|
|
local json_payload="$2"
|
|
ensure_dir "$(dirname "$MCP_CONFIG_PATH")"
|
|
python3 - "$MCP_CONFIG_PATH" "$name" "$json_payload" <<'PY'
|
|
import json, pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
name = sys.argv[2]
|
|
payload = json.loads(sys.argv[3])
|
|
if path.exists():
|
|
try:
|
|
data = json.loads(path.read_text(encoding='utf-8'))
|
|
except Exception:
|
|
data = {}
|
|
else:
|
|
data = {}
|
|
data.setdefault('mcpServers', {})[name] = payload
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
print(path)
|
|
PY
|
|
log_ok "MCP server '$name' registered in $MCP_CONFIG_PATH"
|
|
}
|
|
|
|
test_tcp_port() {
|
|
local port="$1"
|
|
python3 - "$port" <<'PY' >/dev/null 2>&1
|
|
import socket, sys
|
|
port=int(sys.argv[1])
|
|
s=socket.socket()
|
|
s.settimeout(1)
|
|
s.connect(('127.0.0.1', port))
|
|
PY
|
|
}
|
|
|
|
test_mcp_http() {
|
|
local port="$1"
|
|
local timeout_seconds="${2:-3}"
|
|
python3 - "$port" "$timeout_seconds" <<'PY' >/dev/null 2>&1
|
|
import sys, json, urllib.request
|
|
port = int(sys.argv[1])
|
|
timeout = int(sys.argv[2])
|
|
body = json.dumps({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}).encode()
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/mcp",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST"
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
exit(0 if resp.status == 200 else 1)
|
|
except Exception:
|
|
exit(1)
|
|
PY
|
|
}
|
|
|
|
wait_for_port() {
|
|
local port="$1"
|
|
local timeout_seconds="${2:-90}"
|
|
local elapsed=0
|
|
while (( elapsed < timeout_seconds )); do
|
|
if test_tcp_port "$port"; then return 0; fi
|
|
sleep 2
|
|
elapsed=$((elapsed+2))
|
|
done
|
|
return 1
|
|
}
|
|
|
|
manual_required() {
|
|
local name="$1"
|
|
local hint="$2"
|
|
log_warn "MANUAL_INSTALL_REQUIRED: $name — $hint"
|
|
}
|
|
|
|
is_ready_cmd() {
|
|
local cmd="$1"
|
|
has_cmd "$cmd"
|
|
}
|
|
|
|
ensure_jeb_pro() {
|
|
if has_cmd jeb_wincon || has_cmd jeb; then
|
|
log_ok "JEB Pro ready: $(cmd_path jeb_wincon)$(cmd_path jeb)"
|
|
return 0
|
|
fi
|
|
manual_required jeb-pro "JEB Pro is commercial. Install it with a valid PNF Software license, then refresh the tool index."
|
|
LAST_CAPABILITY_MANUAL=true
|
|
MANUAL_REQUIRED=true
|
|
return 0
|
|
}
|
|
|
|
ensure_jadx() {
|
|
if has_cmd jadx; then log_ok "jadx ready: $(cmd_path jadx)"; return 0; fi
|
|
ensure_java_runtime
|
|
local repo re tag sha
|
|
repo=$(manifest_field jadx repo) || return 1
|
|
re=$(manifest_field jadx assetRegex) || return 1
|
|
tag=$(manifest_field jadx releaseTag) || return 1
|
|
sha=$(manifest_field jadx assetSha256) || return 1
|
|
case "$PLATFORM" in
|
|
macos) install_brew jadx || install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;;
|
|
linux) install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;;
|
|
esac
|
|
}
|
|
|
|
ensure_apktool() {
|
|
if has_cmd apktool; then log_ok "apktool ready: $(cmd_path apktool)"; return 0; fi
|
|
ensure_java_runtime
|
|
case "$PLATFORM" in
|
|
macos) install_brew apktool ;;
|
|
linux)
|
|
if install_apt apktool; then return 0; fi
|
|
ensure_dir "$TOOLS_ROOT/apktool"
|
|
local meta url digest jar wrapper
|
|
local repo tag sha re
|
|
repo=$(manifest_field apktool repo) || return 1
|
|
tag=$(manifest_field apktool releaseTag) || return 1
|
|
sha=$(manifest_field apktool assetSha256) || return 1
|
|
re=$(manifest_field apktool assetRegex) || return 1
|
|
meta=$(latest_github_asset_meta "$repo" "$re" "$tag")
|
|
url=$(printf '%s' "$meta" | cut -f1)
|
|
digest=$(printf '%s' "$meta" | cut -f2)
|
|
jar="$TOOLS_ROOT/apktool/apktool.jar"
|
|
curl -L -o "$jar" "$url"
|
|
verify_sha256 "$jar" "$sha" "$digest" || return 1
|
|
wrapper="$TOOLS_ROOT/apktool/apktool"
|
|
printf '#!/usr/bin/env bash\njava -jar "%s" "$@"\n' "$jar" > "$wrapper"
|
|
chmod +x "$wrapper"
|
|
export PATH="$TOOLS_ROOT/apktool:$PATH"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
ensure_frida_tools() {
|
|
ensure_python_runtime || return 1
|
|
if has_cmd frida && has_cmd frida-ps; then log_ok "frida-tools ready"; return 0; fi
|
|
local package
|
|
package=$(manifest_field frida pipPackage) || return 1
|
|
pipx install --force "$package" || return 1
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
}
|
|
|
|
ensure_idalib_mcp() {
|
|
ensure_python_runtime || return 1
|
|
if has_cmd ida-pro-mcp; then log_ok "ida-pro-mcp ready: $(cmd_path ida-pro-mcp)"; return 0; fi
|
|
local source
|
|
source=$(manifest_field idalib-mcp pipSource) || return 1
|
|
pipx install --force "$source" || return 1
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
log_warn "Post-install: run 'ida-pro-mcp --install', choose Streamable HTTP + Global, then restart IDA Pro."
|
|
}
|
|
|
|
ensure_jshookmcp() {
|
|
ensure_node_runtime || return 1
|
|
local package
|
|
package=$(manifest_field jshookmcp npmPackage) || return 1
|
|
write_mcp_server "jshook" "$(python3 - "$package" <<'PY'
|
|
import json, sys
|
|
print(json.dumps({'command':'npx','args':['-y',sys.argv[1]],'env':{'JSHOOK_BASE_PROFILE':'search'}}))
|
|
PY
|
|
)"
|
|
}
|
|
|
|
ensure_reqable_mcp() {
|
|
ensure_node_runtime || return 1
|
|
local package
|
|
package=$(manifest_field reqable-mcp npmPackage) || return 1
|
|
write_mcp_server "reqable-mcp" "$(python3 - "$package" <<'PY'
|
|
import json, sys
|
|
print(json.dumps({'command':'npx','args':['-y',sys.argv[1]]}))
|
|
PY
|
|
)"
|
|
log_warn "Reqable MCP requires the separately installed Reqable desktop application and its local API."
|
|
}
|
|
|
|
ensure_anything_analyzer() {
|
|
local dir="$TOOLS_ROOT/anything-analyzer"
|
|
local repo commit
|
|
repo=$(manifest_field anything-analyzer repoUrl) || return 1
|
|
commit=$(manifest_field anything-analyzer pinnedCommit) || return 1
|
|
if ! has_cmd git; then
|
|
case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac
|
|
fi
|
|
install_git_commit "$repo" "$commit" "$dir" || return 1
|
|
ensure_node_runtime || return 1
|
|
ensure_pnpm || return 1
|
|
write_mcp_server "anything-analyzer" '{"url":"http://localhost:23816/mcp"}'
|
|
if $START_SERVICES; then
|
|
(cd "$dir" && pnpm install --frozen-lockfile) || return 1
|
|
install_git_commit "$repo" "$commit" "$dir" || return 1
|
|
(
|
|
cd "$dir" || exit 1
|
|
if has_cmd nohup; then
|
|
nohup pnpm dev >/tmp/anything-analyzer.log 2>&1 &
|
|
else
|
|
pnpm dev >/tmp/anything-analyzer.log 2>&1 &
|
|
fi
|
|
)
|
|
if wait_for_port 23816 120; then
|
|
if test_mcp_http 23816; then
|
|
log_ok "anything-analyzer MCP server ready on port 23816 (HTTP verified)"
|
|
else
|
|
log_warn "anything-analyzer port 23816 open but MCP HTTP handshake failed; check /tmp/anything-analyzer.log"
|
|
fi
|
|
else
|
|
log_warn "anything-analyzer did not open port 23816; see /tmp/anything-analyzer.log"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
ensure_idapro() {
|
|
ensure_idalib_mcp
|
|
write_mcp_server "idapro" '{"url":"http://127.0.0.1:13337/mcp"}'
|
|
if $START_SERVICES; then
|
|
case "$PLATFORM" in
|
|
linux)
|
|
log_warn "Linux package does not include an IDA GUI launcher. Start IDA manually and ensure MCP listens on 127.0.0.1:13337."
|
|
;;
|
|
macos)
|
|
log_warn "Start IDA Pro manually on macOS and confirm MCP port in the IDA Output window."
|
|
;;
|
|
esac
|
|
wait_for_port 13337 45 || log_warn "idapro MCP service is not online on port 13337 yet."
|
|
if test_mcp_http 13337 3; then
|
|
log_ok "idapro MCP server ready (HTTP verified)"
|
|
else
|
|
log_warn "idapro port 13337 open but MCP HTTP handshake failed; check IDA Output window"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
ensure_r2() {
|
|
if has_cmd r2; then log_ok "r2 ready: $(cmd_path r2)"; return 0; fi
|
|
case "$PLATFORM" in
|
|
macos) install_brew radare2 ;;
|
|
linux)
|
|
if install_apt radare2; then return 0; fi
|
|
manual_required r2 "Install radare2 from GitHub/source: https://github.com/radareorg/radare2"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
ensure_adb() {
|
|
if has_cmd adb; then log_ok "adb ready: $(cmd_path adb)"; return 0; fi
|
|
case "$PLATFORM" in
|
|
macos) install_brew android-platform-tools ;;
|
|
linux) install_apt adb || manual_required adb "Install Android platform-tools from https://developer.android.com/tools/releases/platform-tools" ;;
|
|
esac
|
|
}
|
|
|
|
ensure_agent_browser() {
|
|
ensure_node_runtime || return 1
|
|
if has_cmd agent-browser; then log_ok "agent-browser ready"; return 0; fi
|
|
local package
|
|
package=$(manifest_field agent-browser npmPackage) || return 1
|
|
npm install -g "$package" || return 1
|
|
if has_cmd npx; then npx playwright install chromium || true; fi
|
|
local setup="$SKILL_ROOT/browser-automation/scripts/setup.sh"
|
|
if [[ -x "$setup" ]]; then "$setup" --skip-browser-install || true; fi
|
|
}
|
|
|
|
ensure_ghidra_mcp() {
|
|
ensure_java_runtime || return 1
|
|
local repo regex
|
|
repo=$(manifest_field ghidra-mcp repo) || return 1
|
|
regex=$(manifest_field ghidra-mcp assetRegex) || return 1
|
|
case "$PLATFORM" in
|
|
macos)
|
|
if ! has_cmd ghidraRun && [[ ! -d /Applications/Ghidra.app ]]; then
|
|
install_brew ghidra || brew install --cask ghidra || true
|
|
fi
|
|
;;
|
|
linux)
|
|
if ! has_cmd ghidraRun; then
|
|
install_github_release "$repo" "$regex" "$TOOLS_ROOT/ghidra" || \
|
|
manual_required ghidra-mcp "Install Ghidra from GitHub release or Flatpak, then configure ghidra-mcp if used."
|
|
fi
|
|
;;
|
|
esac
|
|
log_warn "ghidra-mcp requires local Ghidra MCP plugin/server setup. See docs/platforms/$( [[ "$PLATFORM" == macos ]] && echo macos || echo linux ).md"
|
|
}
|
|
|
|
ensure_seclists() {
|
|
local dir="$TOOLS_ROOT/SecLists"
|
|
if [[ -d /usr/share/seclists ]]; then log_ok "SecLists ready"; return 0; fi
|
|
if ! has_cmd git; then case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac; fi
|
|
local repo commit
|
|
repo=$(manifest_field seclists repo) || return 1
|
|
commit=$(manifest_field seclists pinnedCommit) || return 1
|
|
install_git_commit "$repo" "$commit" "$dir" || return 1
|
|
}
|
|
|
|
ensure_proxycat() {
|
|
ensure_python_runtime || return 1
|
|
if has_cmd proxycat; then log_ok "proxycat ready"; return 0; fi
|
|
local repo commit
|
|
repo=$(manifest_field proxycat repo) || return 1
|
|
commit=$(manifest_field proxycat pinnedCommit) || return 1
|
|
pipx install "git+${repo}@${commit}" || {
|
|
manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'."
|
|
LAST_CAPABILITY_MANUAL=true
|
|
MANUAL_REQUIRED=true
|
|
return 0
|
|
}
|
|
}
|
|
|
|
ensure_burpsuite_mcp() {
|
|
local bridge_json
|
|
bridge_json=$(python3 - "$REPO_ROOT/burp-mcp-full/mcp-bridge.js" <<'PY'
|
|
import json, sys
|
|
print(json.dumps({"command":"node","args":[sys.argv[1]]}))
|
|
PY
|
|
)
|
|
write_mcp_server "burpsuite" "$bridge_json"
|
|
manual_required burpsuite-mcp "Build burp-mcp-full and load build/libs/burp-mcp-full.jar in BurpSuite Extensions."
|
|
}
|
|
|
|
ensure_nmap() {
|
|
if has_cmd nmap; then log_ok "nmap ready"; return 0; fi
|
|
case "$PLATFORM" in macos) install_brew nmap ;; linux) install_apt nmap ;; esac
|
|
}
|
|
|
|
register_pentestswarm_mcp() {
|
|
local executable="$1"
|
|
local definition
|
|
definition=$(python3 - "$executable" <<'PY'
|
|
import json, sys
|
|
print(json.dumps({"command": sys.argv[1], "args": ["mcp", "serve"]}))
|
|
PY
|
|
)
|
|
write_mcp_server "pentestswarm" "$definition"
|
|
}
|
|
|
|
ensure_pentestswarm() {
|
|
local pentestswarm_path
|
|
pentestswarm_path="$(cmd_path pentestswarm)"
|
|
if [[ -n "$pentestswarm_path" ]]; then
|
|
register_pentestswarm_mcp "$pentestswarm_path"
|
|
log_ok "pentestswarm ready"
|
|
return 0
|
|
fi
|
|
if ! has_cmd go; then
|
|
case "$PLATFORM" in macos) install_brew go ;; linux) install_apt golang-go ;; esac
|
|
fi
|
|
local go_package docker_image
|
|
go_package=$(manifest_field pentestswarm goPackage) || return 1
|
|
docker_image=$(manifest_field pentestswarm dockerImage) || return 1
|
|
if go install "$go_package"; then
|
|
local go_bin
|
|
go_bin="$(go env GOBIN 2>/dev/null || true)"
|
|
if [[ -z "$go_bin" ]]; then
|
|
go_bin="$(go env GOPATH 2>/dev/null || true)/bin"
|
|
fi
|
|
if [[ -x "$go_bin/pentestswarm" ]]; then
|
|
register_pentestswarm_mcp "$go_bin/pentestswarm"
|
|
log_ok "pentestswarm installed and registered"
|
|
return 0
|
|
fi
|
|
log_warn "pentestswarm installed but no executable was found in GOBIN/GOPATH; trying Docker fallback"
|
|
fi
|
|
if has_cmd docker; then
|
|
write_mcp_server "pentestswarm" "$(python3 - "$docker_image" <<'PY'
|
|
import json, sys
|
|
print(json.dumps({'command':'docker','args':['run','--rm','-i',sys.argv[1],'mcp','serve']}))
|
|
PY
|
|
)"
|
|
log_warn "pentestswarm Go install failed or produced no runnable binary; registered Docker fallback $docker_image"
|
|
else
|
|
manual_required pentestswarm "Install Go 1.24+ or Docker, then install Pentest-Swarm-AI and ensure pentestswarm is on PATH."
|
|
fi
|
|
}
|
|
|
|
ensure_binwalk() {
|
|
if has_cmd binwalk; then log_ok "binwalk ready: $(cmd_path binwalk)"; return 0; fi
|
|
case "$PLATFORM" in
|
|
macos) install_brew binwalk ;;
|
|
linux) install_apt binwalk || manual_required binwalk "git clone https://github.com/ReFirmLabs/binwalk.git and install manually" ;;
|
|
esac
|
|
}
|
|
|
|
ensure_yara() {
|
|
if has_cmd yara; then log_ok "yara ready: $(cmd_path yara)"; return 0; fi
|
|
case "$PLATFORM" in
|
|
macos) install_brew yara ;;
|
|
linux) install_apt yara || manual_required yara "Install from source: https://github.com/VirusTotal/yara" ;;
|
|
esac
|
|
}
|
|
|
|
ensure_pwntools() {
|
|
ensure_python_runtime || return 1
|
|
if python3 -c "import pwn" 2>/dev/null; then log_ok "pwntools ready"; return 0; fi
|
|
local package
|
|
package=$(manifest_field pwntools pipPackage) || return 1
|
|
pipx install "$package" || python3 -m pip install --user "$package" || return 1
|
|
}
|
|
|
|
status_json_line() {
|
|
local name="$1"
|
|
local status="$2"
|
|
local extra="${3:-}"
|
|
if [[ -n "$extra" ]]; then
|
|
printf '{"name":"%s","status":"%s","note":"%s"}\n' "$name" "$status" "$extra"
|
|
else
|
|
printf '{"name":"%s","status":"%s"}\n' "$name" "$status"
|
|
fi
|
|
}
|
|
|
|
cap_depends() {
|
|
case "$1" in
|
|
idapro) echo "idalib-mcp idapro" ;;
|
|
frida-ps) echo "frida frida-ps" ;;
|
|
rabin2) echo "r2 rabin2" ;;
|
|
*) echo "$1" ;;
|
|
esac
|
|
}
|
|
|
|
expand_capabilities() {
|
|
local seen=" "
|
|
local out=()
|
|
local cap dep
|
|
for cap in "$@"; do
|
|
for dep in $(cap_depends "$cap"); do
|
|
if [[ "$seen" != *" $dep "* ]]; then
|
|
out+=("$dep")
|
|
seen+="$dep "
|
|
fi
|
|
done
|
|
done
|
|
printf '%s\n' "${out[@]}"
|
|
}
|
|
|
|
ensure_capability() {
|
|
local name="$1"
|
|
case "$name" in
|
|
jeb-pro) ensure_jeb_pro ;;
|
|
jadx) ensure_jadx ;;
|
|
apktool) ensure_apktool ;;
|
|
frida|frida-ps) ensure_frida_tools ;;
|
|
idalib-mcp) ensure_idalib_mcp ;;
|
|
jshookmcp) ensure_jshookmcp ;;
|
|
reqable-mcp) ensure_reqable_mcp ;;
|
|
anything-analyzer) ensure_anything_analyzer ;;
|
|
idapro) ensure_idapro ;;
|
|
r2|rabin2) ensure_r2 ;;
|
|
adb) ensure_adb ;;
|
|
agent-browser) ensure_agent_browser ;;
|
|
ghidra-mcp) ensure_ghidra_mcp ;;
|
|
seclists) ensure_seclists ;;
|
|
proxycat) ensure_proxycat ;;
|
|
burpsuite-mcp) ensure_burpsuite_mcp ;;
|
|
nmap) ensure_nmap ;;
|
|
pentestswarm) ensure_pentestswarm ;;
|
|
binwalk) ensure_binwalk ;;
|
|
yara) ensure_yara ;;
|
|
pwntools) ensure_pwntools ;;
|
|
*) log_err "No bootstrap definition for capability: $name"; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
RESULTS_FILE="$(mktemp)"
|
|
trap 'rm -f "$RESULTS_FILE"' EXIT
|
|
|
|
# macOS ships Bash 3.2, which has no mapfile/readarray. Keep this path portable
|
|
# instead of requiring users to install a newer Bash just to run the bootstrapper.
|
|
EXPANDED=()
|
|
while IFS= read -r capability; do
|
|
[[ -n "$capability" ]] || continue
|
|
EXPANDED+=("$capability")
|
|
done < <(expand_capabilities "${CAPABILITIES[@]}")
|
|
|
|
log_info "platform=$PLATFORM doc=$(platform_doc) tools_root=$TOOLS_ROOT"
|
|
|
|
if ! ensure_python_interpreter; then
|
|
log_err "Python 3 is required to read bootstrap-manifest.json; no capability was executed."
|
|
exit 1
|
|
fi
|
|
|
|
for cap in "${EXPANDED[@]}"; do
|
|
log_info "ensure $cap"
|
|
LAST_CAPABILITY_MANUAL=false
|
|
if ensure_capability "$cap"; then
|
|
if $LAST_CAPABILITY_MANUAL; then
|
|
status_json_line "$cap" "manual-required" "see $(platform_doc)" >> "$RESULTS_FILE"
|
|
else
|
|
status_json_line "$cap" "ready" >> "$RESULTS_FILE"
|
|
fi
|
|
else
|
|
status_json_line "$cap" "failed" "see $(platform_doc)" >> "$RESULTS_FILE"
|
|
FAILED=true
|
|
fi
|
|
done
|
|
|
|
if ! $SKIP_REFRESH; then
|
|
bash "$SCRIPT_DIR/refresh-tool-index.sh" >/dev/null || log_warn "refresh-tool-index.sh failed"
|
|
fi
|
|
|
|
FINAL_EXIT_CODE=0
|
|
if $FAILED; then
|
|
FINAL_EXIT_CODE=1
|
|
elif $MANUAL_REQUIRED; then
|
|
FINAL_EXIT_CODE=2
|
|
fi
|
|
|
|
if has_cmd python3; then
|
|
python3 - "$RESULTS_FILE" <<'PY'
|
|
import json, sys
|
|
items=[]
|
|
with open(sys.argv[1], encoding='utf-8') as f:
|
|
for line in f:
|
|
if line.strip(): items.append(json.loads(line))
|
|
print(json.dumps(items, ensure_ascii=False, indent=2))
|
|
PY
|
|
else
|
|
log_warn "Python 3 is unavailable; emitting bootstrap results without JSON formatting"
|
|
cat "$RESULTS_FILE"
|
|
fi
|
|
|
|
exit "$FINAL_EXIT_CODE"
|