diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f61d972..1e87570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,55 @@ on: branches: [main] jobs: + installers: + name: Installer tests (${{ matrix.os }}) + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Shell installer tests + run: | + bash -n install.sh tests/install_sh_test.sh + tests/install_sh_test.sh + + - name: ShellCheck + if: runner.os == 'Linux' + run: shellcheck install.sh tests/install_sh_test.sh tests/install_manifest_test.sh + + - name: Version manifest consistency + if: runner.os == 'Linux' + run: tests/install_manifest_test.sh + + installer-arch: + name: Installer smoke test (Arch Linux) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run isolated tests in Arch Linux + run: | + docker run --rm -v "$PWD:/workspace" -w /workspace archlinux:latest \ + bash -c 'bash -n install.sh tests/install_sh_test.sh && tests/install_sh_test.sh' + + installer-windows: + name: Installer tests (Windows) + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: PowerShell installer tests + shell: pwsh + run: tests/install_ps1_test.ps1 + lint: runs-on: ubuntu-latest @@ -59,7 +108,7 @@ jobs: docker: runs-on: ubuntu-latest - needs: test + needs: [test, installers, installer-arch, installer-windows] steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85a5478..c1c52a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,12 +51,13 @@ jobs: VERSION: ${{ steps.version.outputs.tag }} run: | BINARY=scanforge + TOOLS_VERSIONS=$(awk -F= 'BEGIN { sep="" } !/^#/ && NF == 2 { printf "%s%s=%s", sep, $1, $2; sep="," }' .tools-version) if [ "$GOOS" = "windows" ]; then BINARY=scanforge.exe fi go build \ - -ldflags "-s -w -X github.com/MikeRoss27/scanforge/internal/version.Version=${VERSION} -X github.com/MikeRoss27/scanforge/internal/version.Commit=${GITHUB_SHA::7} -X github.com/MikeRoss27/scanforge/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -ldflags "-s -w -X github.com/MikeRoss27/scanforge/internal/version.Version=${VERSION} -X github.com/MikeRoss27/scanforge/internal/version.Commit=${GITHUB_SHA::7} -X github.com/MikeRoss27/scanforge/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/MikeRoss27/scanforge/internal/dependencies.PinnedVersions=${TOOLS_VERSIONS}" \ -o "dist/${BINARY}" \ ./cmd/scanforge @@ -103,7 +104,7 @@ jobs: run: | mkdir -p dist cp -a artifacts/. dist/ - (cd dist && sha256sum *.tar.gz *.zip 2>/dev/null > checksums.txt || true) + (cd dist && sha256sum *.tar.gz *.zip > checksums.txt) - name: Create GitHub Release uses: softprops/action-gh-release@v3 diff --git a/.qwen/skills/auto-skill-git-cross-env-divergence/SKILL.md b/.qwen/skills/auto-skill-git-cross-env-divergence/SKILL.md new file mode 100644 index 0000000..a93f33f --- /dev/null +++ b/.qwen/skills/auto-skill-git-cross-env-divergence/SKILL.md @@ -0,0 +1,87 @@ +--- +name: git-cross-env-divergence +description: Diagnose and resolve git pull/rebase failures caused by uncommitted local work that overlaps or duplicates work already pushed from another environment (e.g. WSL2 vs Windows clones of the same repo). +source: auto-skill +extracted_at: '2026-08-17T21:42:19.793Z' +--- + +# Git cross-environment divergence (WSL2 ↔ Windows) + +Use this when `git pull --rebase` (or VS Code's Sync) fails with +`cannot pull with rebase: You have unstaged changes`, and the user suspects a +dual-environment (e.g. WSL2 Ubuntu + Windows) repo split. + +## Symptom signature + +- `git pull --tags -r origin main` → `error: cannot pull with rebase: You have unstaged changes.` +- Root cause is almost always: the *other* environment pushed N commits to + `origin` while *this* clone has uncommitted work on the same files. + +## Diagnose before acting (don't stash blindly) + +1. `git status` — look for the two-part signature: branch is **behind + `origin/main` by N commits** *and* has uncommitted changes. Both together = + the classic dual-environment divergence. +2. `git rev-parse --show-toplevel` — confirm which working tree you're in + (`D:/...` = Windows, `/mnt/d/...` or `~/...` = WSL2 clone). +3. Detect overlap between local work and the incoming commits: + - `git log --oneline ..origin/main -- ` + - If those commits touch the **same files** as the local uncommitted work, + expect real conflicts, not a clean fast-forward. + +## Detect a *divergent/duplicate* implementation (the non-obvious step) + +When local changes and incoming commits both touch the same feature, ask +whether the local work is a parallel re-implementation of something already +merged upstream. Two cheap probes: + +- `git ls-tree origin/main ` — empty output means the file does + NOT exist on the remote; it is local-only. +- `git log --oneline --all --diff-filter=AD -- ` — empty across **all** + refs means the file was **never committed anywhere**. A feature-named commit + that touches *related* files in the incoming range, but a local file that + appears nowhere in history, means the same feature was implemented + differently (e.g. inline in `events.go` instead of a separate `findings.go`). + +This determines the resolution: **merge** (independent work) vs **discard one +side** (superseded duplicate). + +## Resolution + +1. `git stash push -u -m "wip: "` — the `-u` is mandatory to + capture **untracked** files too. +2. `git pull --rebase --tags origin main` — when the branch is strictly behind + ("behind by N commits, can be fast-forwarded"), this **fast-forwards** with + no real rebase and no conflicts (there are no local commits to replay). The + "rebase" wording in the error is misleading in this case. +3. `git stash pop` — conflicts (if any) surface here, on files changed in both. + Safety: a `stash pop` that hits conflicts **keeps the stash entry** in + `git stash list`, so nothing is lost yet. +4. Decide keep vs discard based on step "detect divergent implementation": + - **Discard** (superseded work): + `git restore --source=HEAD --staged --worktree -- `, delete the + untracked file (`del /f /q ` on Windows), then `git stash drop`. + - **Keep**: resolve each conflict marker normally (`<<<<<<< Updated upstream` + vs `>>>>>>> Stashed changes`) and reconcile both sides. + +## Verify + +- `git status` → `working tree clean`, `up to date with 'origin/main'`. +- No `<<<<<<<`/`=======`/`>>>>>>>` markers remain. +- `git rev-parse HEAD` equals `git rev-parse origin/main`. +- `go build ./...` passes (for Go repos). + +## Prevention + +Working across two clones of the same repo (WSL2 + Windows): always `git +status` + `git pull` in the environment you're about to edit, or use a +dedicated branch per environment, so uncommitted work doesn't silently drift +out of sync with what the other side already pushed. + +## Why + +Local uncommitted work and already-pushed work are frequently the *same +feature written twice* — a fact invisible from `git status` alone but exposed +by `git log --all --diff-filter=AD -- ` returning empty. Classifying the +divergence (duplicate vs independent) *before* resolving conflicts avoids a +broken hybrid or wasted merge effort. \ No newline at end of file diff --git a/.tools-version b/.tools-version index 2ad2484..68d8b10 100644 --- a/.tools-version +++ b/.tools-version @@ -1,4 +1,4 @@ -# Versions épinglées des outils externes (source unique : install.sh et Dockerfile) +# Versions et empreintes épinglées (source unique : install.sh, install.ps1 et Dockerfile) SUBFINDER_VERSION=v2.15.0 DNSX_VERSION=v1.3.0 HTTPX_VERSION=v1.10.0 @@ -8,4 +8,9 @@ NUCLEI_VERSION=v3.11.0 TLSX_VERSION=v1.3.2 GAU_VERSION=v2.2.4 FFUF_VERSION=v2.2.1 -SHUFFLEDNS_VERSION=v1.1.1 +SHUFFLEDNS_VERSION=v1.2.1 +SECLISTS_VERSION=2026.1 +SECLISTS_DNS_SHA256=e331367c140298cb179114fdeefa78f58f696219f0dec017a28bb79487cfcf19 +MASSDNS_VERSION=v1.1.0 +MASSDNS_SOURCE_SHA256=93b14431496b358ee9f3a5b71bd9618fe4ff1af8c420267392164f7b2d949559 +WAFW00F_VERSION=v2.4.2 diff --git a/Dockerfile b/Dockerfile index 14ae48f..da325ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ FROM golang:1.26-bookworm AS build RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ ca-certificates \ + curl \ && rm -rf /var/lib/apt/lists/* # Versions épinglées des outils (source unique : .tools-version) @@ -21,12 +23,29 @@ RUN . /tmp/tools-version && \ GOBIN=/out go install github.com/ffuf/ffuf/v2@${FFUF_VERSION} && \ GOBIN=/out go install github.com/projectdiscovery/shuffledns/cmd/shuffledns@${SHUFFLEDNS_VERSION} +# Wordlist DNS minimale requise par shuffledns, épinglée et vérifiée. +RUN . /tmp/tools-version && \ + mkdir -p /wordlists && \ + curl -fsSL "https://raw.githubusercontent.com/danielmiessler/SecLists/${SECLISTS_VERSION}/Discovery/DNS/subdomains-top1million-5000.txt" \ + -o /wordlists/subdomains-top1million-5000.txt && \ + echo "${SECLISTS_DNS_SHA256} /wordlists/subdomains-top1million-5000.txt" | sha256sum -c - + +RUN . /tmp/tools-version && \ + curl -fsSL "https://github.com/blechschmidt/massdns/archive/refs/tags/${MASSDNS_VERSION}.tar.gz" -o /tmp/massdns.tar.gz && \ + echo "${MASSDNS_SOURCE_SHA256} /tmp/massdns.tar.gz" | sha256sum -c - && \ + tar -xzf /tmp/massdns.tar.gz -C /tmp && \ + make -C "/tmp/massdns-${MASSDNS_VERSION#v}" && \ + cp "/tmp/massdns-${MASSDNS_VERSION#v}/bin/massdns" /out/massdns + # Compilation de ScanForge (binaire statique, sans cache) WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/scanforge ./cmd/scanforge +RUN TOOLS_VERSIONS="$(awk -F= 'BEGIN { sep="" } !/^#/ && NF == 2 { printf "%s%s=%s", sep, $1, $2; sep="," }' /tmp/tools-version)" && \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X github.com/MikeRoss27/scanforge/internal/dependencies.PinnedVersions=${TOOLS_VERSIONS}" \ + -o /out/scanforge ./cmd/scanforge # Stage 2 : image d'exécution minimale FROM debian:bookworm-slim @@ -35,15 +54,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ chromium \ nmap \ - massdns \ + pipx \ python3 \ - python3-pip \ python3-venv \ whatweb \ - wafw00f \ && rm -rf /var/lib/apt/lists/* +COPY .tools-version /tmp/tools-version +RUN . /tmp/tools-version && PIPX_BIN_DIR=/usr/local/bin pipx install "wafw00f==${WAFW00F_VERSION#v}" + COPY --from=build /out/ /usr/local/bin/ +COPY --from=build /wordlists/ /usr/share/scanforge/wordlists/ # Répertoire de travail final (celui qui sera monté par l'utilisateur) WORKDIR /workspace diff --git a/Makefile b/Makefile index fb0fc08..ef1ee35 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,12 @@ GOLANGCI := golangci-lint VERSION ?= dev COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +TOOLS_VERSIONS := $(shell awk -F= 'BEGIN { sep="" } substr($$0,1,1) != sprintf("%c",35) && NF == 2 { printf "%s%s=%s", sep, $$1, $$2; sep="," }' .tools-version) LDFLAGS := -s -w \ -X github.com/MikeRoss27/scanforge/internal/version.Version=$(VERSION) \ -X github.com/MikeRoss27/scanforge/internal/version.Commit=$(COMMIT) \ - -X github.com/MikeRoss27/scanforge/internal/version.Date=$(DATE) + -X github.com/MikeRoss27/scanforge/internal/version.Date=$(DATE) \ + -X github.com/MikeRoss27/scanforge/internal/dependencies.PinnedVersions=$(TOOLS_VERSIONS) .PHONY: all build test race vet lint fmt install docker clean diff --git a/README.md b/README.md index 9f1225e..dfeee70 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.s ### Option 2: Full installation (binary + scan tools) -ScanForge orchestrates external tools (nmap, nuclei, subfinder, httpx, ...). To install them automatically **on top of** ScanForge (requires Go): +ScanForge orchestrates external tools (nmap, nuclei, subfinder, httpx, ...). `--full` installs dependencies that have a reliable unattended method (a recent Go remains required on Debian/Ubuntu and Windows): ```bash curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.sh | bash -s -- --full @@ -116,9 +116,16 @@ chmod +x install.sh && ./install.sh --full # Linux / macOS .\install.ps1 -Full # Windows (PowerShell) ``` +- Arch uses only official pacman packages (`nmap`, `chromium`, `go`, `python-pipx`, `base-devel`) and never runs `pacman -Syu`. Pinned Go tools use `go install`, `wafw00f` uses pipx, and verified upstream artifacts provide massdns and the DNS wordlist. WhatWeb remains manual/AUR-only; no AUR helper is assumed. +- Debian/Ubuntu installs packages available in the current apt release, builds verified massdns when needed, and never modifies system Python with global pip. +- macOS uses Homebrew, pinned Go tools and pipx; WhatWeb and a Chrome-family browser may remain manual. +- Native Windows installs pinned Go tools and uses pipx when available. Nmap, massdns and WhatWeb remain manual; WSL or Docker is recommended for profiles that need them. + +The final verification reports anything still missing. `scanforge doctor --profile NAME` then gives profile-specific status and installation guidance. + ### Option 3: Docker (Zero local installation) -If you don't want to install Go or the other tools on your host system, use Docker. Everything is pre-configured in the image! +If you don't want to install Go or the other tools on your host system, use Docker. Runtime tools, massdns, Chromium and a verified pinned DNS wordlist are included. ```bash # With docker-compose diff --git a/docs/USAGE.md b/docs/USAGE.md index c752471..e2b47c8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -28,6 +28,8 @@ From a clone of the repository, the local scripts do the same: .\install.ps1 -Full ``` +On Arch, `--full` uses `pacman -S --needed` only for official packages and never performs a full system upgrade. Pinned Go tools, isolated pipx and verified upstream artifacts cover the remaining automated dependencies; WhatWeb remains manual/AUR. No global pip install is used, preserving PEP 668 compatibility. The final verification and `scanforge doctor --profile NAME` identify anything still missing. + You can also build the binary locally: ```bash diff --git a/docs/fr/README.md b/docs/fr/README.md index 1c2a9a3..d517253 100644 --- a/docs/fr/README.md +++ b/docs/fr/README.md @@ -105,7 +105,7 @@ curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.s ### Option 2 : Installation complète (binaire + outils de scan) -ScanForge orchestre des outils externes (nmap, nuclei, subfinder, httpx, ...). Pour les installer automatiquement **en plus** de ScanForge (requiert Go) : +ScanForge orchestre des outils externes (nmap, nuclei, subfinder, httpx, ...). `--full` installe les dépendances disposant d'une méthode non interactive fiable (Go récent reste requis sur Debian/Ubuntu et Windows) : ```bash curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.sh | bash -s -- --full @@ -118,9 +118,16 @@ chmod +x install.sh && ./install.sh --full # Linux / macOS .\install.ps1 -Full # Windows (PowerShell) ``` +- Arch utilise uniquement les dépôts officiels pour `nmap`, `chromium`, `go`, `python-pipx` et `base-devel`, sans jamais lancer `pacman -Syu`. Les outils Go sont épinglés, `wafw00f` passe par pipx, et massdns ainsi que la wordlist DNS viennent d'artefacts upstream vérifiés. WhatWeb reste manuel/AUR ; aucun helper AUR n'est supposé. +- Debian/Ubuntu installe les paquets disponibles dans la version apt courante et ne modifie jamais Python système avec un `pip install` global. +- macOS utilise Homebrew, Go et pipx ; WhatWeb et un navigateur Chrome/Chromium peuvent rester manuels. +- Sous Windows natif, Nmap, massdns et WhatWeb restent manuels ; WSL ou Docker est recommandé pour les profils qui les utilisent. + +La vérification finale liste les éventuels manques. `scanforge doctor --profile NOM` fournit ensuite un diagnostic spécifique au profil avec les commandes d'installation adaptées. + ### Option 3 : Docker (Zéro installation locale) -Si vous ne souhaitez pas installer Go ou les autres outils sur votre système hôte, utilisez Docker. Tout est pré-configuré dans l'image ! +Si vous ne souhaitez pas installer Go ou les autres outils sur votre système hôte, utilisez Docker. Les outils runtime, massdns, Chromium et une wordlist DNS épinglée et vérifiée sont inclus. ```bash # Avec docker-compose @@ -315,4 +322,4 @@ Utilisez indifféremment `--preset safe` ou `--profile safe`. Avant un profil ac - `06_vulns/http-checks.jsonl` : Headers de sécurité et flags de cookies manquants (module `httpcheck`). - `06_vulns/nuclei.jsonl` : Findings nuclei bruts (module `nuclei`). -> ScanForge doit uniquement être utilisé sur des actifs pour lesquels vous disposez d'une autorisation explicite. \ No newline at end of file +> ScanForge doit uniquement être utilisé sur des actifs pour lesquels vous disposez d'une autorisation explicite. diff --git a/docs/fr/USAGE.md b/docs/fr/USAGE.md index 42f03f6..6c0250c 100644 --- a/docs/fr/USAGE.md +++ b/docs/fr/USAGE.md @@ -28,6 +28,8 @@ Depuis un clone du dépôt, les scripts locaux font la même chose : .\install.ps1 -Full ``` +Sur Arch, `--full` utilise `pacman -S --needed` pour les seuls paquets officiels, sans mise à niveau globale, puis Go, pipx et des artefacts upstream vérifiés. WhatWeb reste manuel/AUR et aucun helper AUR n'est requis. L'installateur n'effectue aucun `pip install` global (compatibilité PEP 668). La vérification finale et `scanforge doctor --profile NOM` indiquent précisément ce qui manque encore. + Vous pouvez aussi construire le binaire localement : ```bash diff --git a/docs/zh/README.md b/docs/zh/README.md index 30e2c34..8d1549e 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -105,7 +105,7 @@ curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.s ### 方式 2:完整安装(二进制 + 扫描工具) -ScanForge 编排外部工具(nmap、nuclei、subfinder、httpx 等)。如需在 ScanForge 之外自动安装它们(需要 Go): +ScanForge 编排外部工具(nmap、nuclei、subfinder、httpx 等)。`--full` 会安装具备可靠非交互安装方式的依赖(Debian/Ubuntu 和 Windows 仍需预先安装较新的 Go): ```bash curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.sh | bash -s -- --full @@ -118,9 +118,16 @@ chmod +x install.sh && ./install.sh --full # Linux / macOS .\install.ps1 -Full # Windows (PowerShell) ``` +- Arch 仅从官方仓库安装 `nmap`、`chromium`、`go`、`python-pipx` 和 `base-devel`,且绝不运行 `pacman -Syu`。Go 工具使用固定版本,`wafw00f` 通过 pipx 隔离安装,massdns 与 DNS 字典来自经过 SHA-256 验证的上游文件。WhatWeb 仍需手动或通过 AUR 安装,脚本不假设存在 AUR helper。 +- Debian/Ubuntu 只安装当前 apt 版本中存在的软件包,且不会通过全局 pip 修改系统 Python。 +- macOS 使用 Homebrew、Go 和 pipx;WhatWeb 与 Chrome/Chromium 浏览器可能仍需手动安装。 +- 原生 Windows 上的 Nmap、massdns 和 WhatWeb 仍需手动安装;需要这些工具时建议使用 WSL 或 Docker。 + +最终检查会列出所有缺失项,`scanforge doctor --profile NAME` 会给出按 profile 区分的状态和安装提示。 + ### 方式 3:Docker(零本地安装) -如果你不想在宿主机上安装 Go 或其他工具,可以使用 Docker。镜像已预配置好一切! +如果你不想在宿主机上安装 Go 或其他工具,可以使用 Docker。镜像包含运行时工具、massdns、Chromium 以及固定版本并经过验证的 DNS 字典。 ```bash # 使用 docker-compose @@ -313,4 +320,4 @@ webhook: - `06_vulns/http-checks.jsonl`:缺失的安全请求头和 Cookie 标志(`httpcheck` 模块)。 - `06_vulns/nuclei.jsonl`:nuclei 原始发现(`nuclei` 模块)。 -> ScanForge 只能用于你拥有明确授权的资产。 \ No newline at end of file +> ScanForge 只能用于你拥有明确授权的资产。 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 3822093..38a2aaa 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -28,6 +28,8 @@ curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.s .\install.ps1 -Full ``` +在 Arch 上,`--full` 只通过 `pacman -S --needed` 安装官方仓库软件包,不会执行完整系统升级。其余自动化依赖使用固定版本的 Go 工具、隔离的 pipx 环境以及经过 SHA-256 验证的上游文件;WhatWeb 仍需手动或通过 AUR 安装。安装器不会执行全局 `pip install`,因此兼容 PEP 668。最终检查与 `scanforge doctor --profile NAME` 会明确列出仍缺少的项目。 + 你也可以在本地构建二进制文件: ```bash diff --git a/go.mod b/go.mod index 92528aa..3865877 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect diff --git a/go.sum b/go.sum index 545d197..b1b101e 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/install.ps1 b/install.ps1 index 5aad459..22b7da2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,24 +1,9 @@ <# .SYNOPSIS -ScanForge - install script (Windows). - +Installs ScanForge on Windows. .DESCRIPTION -By default, installs the prebuilt ScanForge binary from GitHub Releases -(only PowerShell is required, no Go needed): - - Invoke-Expression (Invoke-RestMethod https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.ps1) - -With the -Full parameter, also installs all external tools -(nmap, wafw00f, subfinder, nuclei, ...) via Go and pip. - -.PARAMETER Full -Also installs the external tools (requires Go). - -.PARAMETER Version -Version to install (default: latest). Example: -Version v0.1.0 - -.PARAMETER InstallDir -Install directory (default: $env:LOCALAPPDATA\Programs\scanforge). +Installs a verified prebuilt binary. -Full also installs pinned Go tools and +wafw00f through pipx when available; unsupported Windows tools are reported. #> param( [switch]$Full, @@ -27,147 +12,226 @@ param( ) $ErrorActionPreference = "Stop" - $Repo = "MikeRoss27/scanforge" $RawBase = "https://raw.githubusercontent.com/$Repo/main" $ApiBase = "https://api.github.com/repos/$Repo" function Write-Info { Write-Host $args -ForegroundColor Cyan } function Write-Ok { Write-Host "[OK] $args" -ForegroundColor Green } -function Write-Warn { Write-Host "[WARNING] $args" -ForegroundColor Yellow } -function Write-Err { Write-Host "[ERROR] $args" -ForegroundColor Red; exit 1 } +function Write-Warn { Write-Warning $args } + +function Get-ScanForgeArchitecture { + param([System.Runtime.InteropServices.Architecture]$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) + if ($Architecture -ne [System.Runtime.InteropServices.Architecture]::X64) { + throw "Unsupported Windows architecture: $Architecture. ScanForge publishes only windows/amd64 releases." + } + return "amd64" +} function Get-ScanForgeVersion { param([string]$Requested) if ($Requested -eq "latest") { Write-Info "Fetching the latest available version..." $release = Invoke-RestMethod "$ApiBase/releases/latest" - return $release.tag_name.TrimStart("v") + $Requested = $release.tag_name } - return $Requested.TrimStart("v") + $resolved = $Requested.TrimStart("v") + if (-not $resolved -or $resolved -notmatch '^[0-9A-Za-z._+-]+$') { + throw "Invalid version: $Requested" + } + return $resolved } -function Install-ScanForge { - $version = Get-ScanForgeVersion $Version - Write-Info "Target version: $version" - - if (-not $InstallDir) { - $InstallDir = "$env:LOCALAPPDATA\Programs\scanforge" +function Get-ChecksumEntry { + param([string]$Content, [string]$Artifact) + foreach ($line in ($Content -split "`r?`n")) { + if ($line -match '^([0-9A-Fa-f]{64})\s+\*?(.+)$' -and $Matches[2] -eq $Artifact) { + return $Matches[1].ToLowerInvariant() + } } - New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null - - $asset = "scanforge_${version}_windows_amd64.zip" - $url = "https://github.com/$Repo/releases/download/v${version}/$asset" - $archive = "$env:TEMP\$asset" - $tmp = Join-Path $env:TEMP ("scanforge-install-" + [guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Force -Path $tmp | Out-Null + return $null +} - Write-Info "Downloading $asset ..." - Invoke-WebRequest $url -OutFile $archive +function Assert-FileChecksum { + param([string]$Path, [string]$Expected, [string]$Label) + if ($Expected -notmatch '^[0-9a-f]{64}$') { + throw "Invalid SHA-256 value for $Label" + } + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Expected) { + throw "SHA-256 mismatch for ${Label}: expected $Expected, got $actual" + } + Write-Ok "SHA-256 verified ($Label)" +} - Expand-Archive -Path $archive -DestinationPath $tmp -Force +function Install-ScanForge { + $architecture = Get-ScanForgeArchitecture + $resolvedVersion = Get-ScanForgeVersion $Version + Write-Info "Target version: $resolvedVersion" - $bin = Get-ChildItem $tmp -Recurse -File -Filter "scanforge*.exe" | Select-Object -First 1 - if (-not $bin) { - Write-Err "Binary not found in the archive" + if (-not $script:InstallDir) { + $script:InstallDir = Join-Path $env:LOCALAPPDATA "Programs\scanforge" } + New-Item -ItemType Directory -Force -Path $script:InstallDir | Out-Null - # Verify the SHA-256 checksum - $expected = "" - $actual = "" + $releaseName = "scanforge_${resolvedVersion}_windows_${architecture}" + $asset = "$releaseName.zip" + $releaseBase = "https://github.com/$Repo/releases/download/v${resolvedVersion}" + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("scanforge-install-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $tempDir | Out-Null try { - $checksums = Invoke-RestMethod "https://github.com/$Repo/releases/download/v${version}/checksums.txt" - $expected = ($checksums -split "`n" | Where-Object { $_ -match "(\s)$([regex]::Escape($asset))$" } | ForEach-Object { ($_ -split "\s+")[0] }) - $actual = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLower() - } catch { - $expected = "" - } - if ($expected -and $actual -and $expected -eq $actual) { - Write-Ok "SHA-256 checksum verified" - } else { - Write-Warn "Unable to verify the SHA-256 checksum" - } + $archive = Join-Path $tempDir $asset + Write-Info "Downloading $asset ..." + Invoke-WebRequest "$releaseBase/$asset" -OutFile $archive + + try { + $checksums = (Invoke-WebRequest "$releaseBase/checksums.txt").Content + $expectedArchive = Get-ChecksumEntry $checksums $asset + if (-not $expectedArchive) { + throw "checksums.txt exists but has no entry for $asset" + } + Assert-FileChecksum $archive $expectedArchive "release archive $asset" + } catch { + $response = $_.Exception.Response + if ($response -and [int]$response.StatusCode -eq 404) { + Write-Warn "Release v$resolvedVersion has no checksums.txt; integrity verification is unavailable for this legacy release" + } else { + throw + } + } - Copy-Item $bin.FullName "$InstallDir\scanforge.exe" -Force - Remove-Item $tmp -Recurse -Force + $extractDir = Join-Path $tempDir "extracted" + Expand-Archive -LiteralPath $archive -DestinationPath $extractDir + $binaryName = "$releaseName.exe" + $binary = Join-Path $extractDir $binaryName + if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { + throw "Expected binary not found in archive: $binaryName" + } - $current = [Environment]::GetEnvironmentVariable("Path", "User") - if ($current -notlike "*$InstallDir*") { - [Environment]::SetEnvironmentVariable("Path", "$current;$InstallDir", "User") - Write-Ok "Directory added to the user PATH (reopen your terminal)" + $embeddedFile = Join-Path $extractDir "$releaseName.sha256" + if (Test-Path -LiteralPath $embeddedFile -PathType Leaf) { + $embeddedExpected = Get-ChecksumEntry (Get-Content -LiteralPath $embeddedFile -Raw) $binaryName + if (-not $embeddedExpected) { + throw "Embedded checksum does not name $binaryName" + } + Assert-FileChecksum $binary $embeddedExpected "extracted binary $binaryName" + } else { + Write-Warn "Archive contains no binary checksum; archive checksum was the only integrity check" + } + + Copy-Item -LiteralPath $binary -Destination (Join-Path $script:InstallDir "scanforge.exe") -Force + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } - Write-Ok "ScanForge $version installed in $InstallDir\scanforge.exe" + $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") + $pathParts = @($currentPath -split ';' | Where-Object { $_ }) + if ($script:InstallDir -notin $pathParts) { + $newPath = (@($pathParts) + $script:InstallDir) -join ';' + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + Write-Ok "Directory added to the user PATH (reopen your terminal)" + } + Write-Ok "ScanForge $resolvedVersion installed in $script:InstallDir\scanforge.exe" } -function Install-Full { - $toolsVersion = @{} - $toolsFile = "$PSScriptRoot\.tools-version" - if (-not (Test-Path $toolsFile)) { +function Get-ToolVersions { + $toolsFile = Join-Path $PSScriptRoot ".tools-version" + $downloadedFile = $null + if (-not (Test-Path -LiteralPath $toolsFile)) { Write-Info "Fetching pinned tool versions (.tools-version)..." - $toolsFile = "$env:TEMP\scanforge-tools-version" - Invoke-WebRequest "$RawBase/.tools-version" -OutFile $toolsFile + $downloadedFile = Join-Path ([System.IO.Path]::GetTempPath()) ("scanforge-tools-" + [guid]::NewGuid().ToString("N")) + Invoke-WebRequest "$RawBase/.tools-version" -OutFile $downloadedFile + $toolsFile = $downloadedFile } - Get-Content $toolsFile | ForEach-Object { - if ($_ -match "^([A-Z_]+)=(.+)$") { - $toolsVersion[$Matches[1]] = $Matches[2] + try { + $versions = @{} + foreach ($line in Get-Content -LiteralPath $toolsFile) { + if (-not $line -or $line.StartsWith('#')) { continue } + if ($line -notmatch '^([A-Z_]+)=([0-9A-Za-z.+-]+)$') { + throw "Invalid entry in .tools-version: $line" + } + $versions[$Matches[1]] = $Matches[2] + } + $required = @('SUBFINDER_VERSION', 'DNSX_VERSION', 'HTTPX_VERSION', 'NAABU_VERSION', 'KATANA_VERSION', 'NUCLEI_VERSION', 'TLSX_VERSION', 'GAU_VERSION', 'FFUF_VERSION', 'SHUFFLEDNS_VERSION', 'SECLISTS_VERSION', 'SECLISTS_DNS_SHA256', 'MASSDNS_VERSION', 'MASSDNS_SOURCE_SHA256', 'WAFW00F_VERSION') + foreach ($key in $required) { + if (-not $versions.ContainsKey($key)) { throw "Missing $key in .tools-version" } } + return $versions + } finally { + if ($downloadedFile) { Remove-Item -LiteralPath $downloadedFile -Force -ErrorAction SilentlyContinue } } +} - try { - $goVersion = go version - Write-Ok "Go is installed: $goVersion" - } catch { - Write-Err "Go is not installed or missing from PATH. Install it from https://go.dev/dl/" +function Install-GoTools { + param([hashtable]$ToolsVersion) + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "Go is required for -Full. Install it from https://go.dev/dl/" } - + Write-Ok "Go is installed: $(go version)" $goTools = @( - "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@$($toolsVersion['SUBFINDER_VERSION'])", - "github.com/projectdiscovery/dnsx/cmd/dnsx@$($toolsVersion['DNSX_VERSION'])", - "github.com/projectdiscovery/httpx/cmd/httpx@$($toolsVersion['HTTPX_VERSION'])", - "github.com/projectdiscovery/naabu/v2/cmd/naabu@$($toolsVersion['NAABU_VERSION'])", - "github.com/projectdiscovery/katana/cmd/katana@$($toolsVersion['KATANA_VERSION'])", - "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@$($toolsVersion['NUCLEI_VERSION'])", - "github.com/projectdiscovery/tlsx/cmd/tlsx@$($toolsVersion['TLSX_VERSION'])", - "github.com/lc/gau/v2/cmd/gau@$($toolsVersion['GAU_VERSION'])", - "github.com/ffuf/ffuf/v2@$($toolsVersion['FFUF_VERSION'])" + "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@$($ToolsVersion.SUBFINDER_VERSION)", + "github.com/projectdiscovery/dnsx/cmd/dnsx@$($ToolsVersion.DNSX_VERSION)", + "github.com/projectdiscovery/httpx/cmd/httpx@$($ToolsVersion.HTTPX_VERSION)", + "github.com/projectdiscovery/naabu/v2/cmd/naabu@$($ToolsVersion.NAABU_VERSION)", + "github.com/projectdiscovery/katana/cmd/katana@$($ToolsVersion.KATANA_VERSION)", + "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@$($ToolsVersion.NUCLEI_VERSION)", + "github.com/projectdiscovery/tlsx/cmd/tlsx@$($ToolsVersion.TLSX_VERSION)", + "github.com/lc/gau/v2/cmd/gau@$($ToolsVersion.GAU_VERSION)", + "github.com/ffuf/ffuf/v2@$($ToolsVersion.FFUF_VERSION)", + "github.com/projectdiscovery/shuffledns/cmd/shuffledns@$($ToolsVersion.SHUFFLEDNS_VERSION)" ) - - Write-Info "Installing Go tools (pinned versions)... This may take a few minutes." foreach ($tool in $goTools) { - Write-Host "-> Installing $tool ..." - go install $tool - if ($LASTEXITCODE -ne 0) { - Write-Warn "Unable to install $tool" - } else { - Write-Ok "Installed" - } + Write-Info "Installing $tool" + & go install $tool + if ($LASTEXITCODE -ne 0) { throw "go install failed: $tool" } + } +} + +function Install-PythonTools { + param([hashtable]$ToolsVersion) + if (Get-Command pipx -ErrorAction SilentlyContinue) { + Write-Info "Installing wafw00f in an isolated pipx environment..." + & pipx install --force "wafw00f==$($ToolsVersion.WAFW00F_VERSION.TrimStart('v'))" + if ($LASTEXITCODE -ne 0) { throw "pipx failed to install wafw00f" } + } elseif (Get-Command wafw00f -ErrorAction SilentlyContinue) { + Write-Warn "wafw00f exists but pipx is unavailable, so its pinned version could not be enforced" + } else { + Write-Warn "pipx is unavailable. Install pipx, then run: pipx install wafw00f" } +} - Write-Info "Installing non-Go tools..." +function Install-DnsWordlist { + param([hashtable]$ToolsVersion) + $wordlistDir = Join-Path $env:LOCALAPPDATA "scanforge\wordlists" + $target = Join-Path $wordlistDir "subdomains-top1million-5000.txt" + $tempFile = Join-Path ([System.IO.Path]::GetTempPath()) ("scanforge-wordlist-" + [guid]::NewGuid().ToString("N")) try { - pip install wafw00f - } catch { - Write-Warn "wafw00f not installed (pip unavailable). Install it manually: pip install wafw00f" + $url = "https://raw.githubusercontent.com/danielmiessler/SecLists/$($ToolsVersion.SECLISTS_VERSION)/Discovery/DNS/subdomains-top1million-5000.txt" + Invoke-WebRequest $url -OutFile $tempFile + Assert-FileChecksum $tempFile $ToolsVersion.SECLISTS_DNS_SHA256 "SecLists DNS wordlist $($ToolsVersion.SECLISTS_VERSION)" + New-Item -ItemType Directory -Force -Path $wordlistDir | Out-Null + Copy-Item -LiteralPath $tempFile -Destination $target -Force + Write-Ok "DNS wordlist installed in $target" + } finally { + Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } - Write-Warn "Nmap: download the installer from https://nmap.org/download.html" - Write-Warn "WhatWeb: mainly usable under Linux/WSL (or via Docker)" +} +function Install-Full { + $versions = Get-ToolVersions + Install-GoTools $versions + Install-PythonTools $versions + Install-DnsWordlist $versions + Write-Warn "Nmap requires the official Windows installer: https://nmap.org/download.html" + Write-Warn "massdns, WhatWeb and DNS wordlists remain manual on native Windows; WSL or Docker is recommended for profiles that require them" Install-ScanForge } -Write-Host "=========================================" -ForegroundColor Cyan -Write-Host " ScanForge Installation (Windows) " -ForegroundColor Cyan -Write-Host "=========================================" -ForegroundColor Cyan -Write-Host "" - -if ($Full) { - Install-Full -} else { - Install-ScanForge +function Invoke-Main { + if ($Full) { Install-Full } else { Install-ScanForge } + Write-Info "Installation complete. Reopen the terminal, then run:" + Write-Host "> scanforge init" -ForegroundColor Yellow + Write-Host "> scanforge doctor" -ForegroundColor Yellow } -Write-Host "" -Write-Info "Installation complete! You can now run:" -Write-Host "> scanforge init" -ForegroundColor Yellow -Write-Host "> scanforge doctor" -ForegroundColor Yellow +if ($env:SCANFORGE_INSTALLER_TESTING -ne '1') { Invoke-Main } diff --git a/install.sh b/install.sh index 8cd5146..7431170 100755 --- a/install.sh +++ b/install.sh @@ -1,208 +1,303 @@ #!/usr/bin/env bash -# ScanForge - install script (Linux / macOS / Git-Bash on Windows) -# -# Quick install (prebuilt binary from GitHub Releases, no Go required): -# curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.sh | bash -# -# Full install (binary + all external tools, requires Go): -# curl -fsSL https://raw.githubusercontent.com/MikeRoss27/scanforge/main/install.sh | bash -s -- --full -# -# Options: -# --full Also install external tools (nmap, nuclei, subfinder, ...) -# --version Version to install (default: latest) -# --dir Install directory (default: ~/.local/bin) -# -h, --help Show help -# -# Environment variables: SCANFORGE_VERSION, SCANFORGE_INSTALL_DIR +# ScanForge installer for Linux and macOS (Git Bash supports binary-only mode). +# Usage: ./install.sh [--full] [--version vX.Y.Z] [--dir PATH] set -euo pipefail REPO="MikeRoss27/scanforge" RAW_BASE="https://raw.githubusercontent.com/${REPO}/main" API_BASE="https://api.github.com/repos/${REPO}" - -VERSION="latest" -INSTALL_DIR="" +VERSION="${SCANFORGE_VERSION:-latest}" +INSTALL_DIR="${SCANFORGE_INSTALL_DIR:-}" MODE="binary" +OS="" +ARCH="" +PACKAGE_MANAGER="" +DEST="" +TEMP_ROOT="" +TOOLS_FILE="" + +info() { printf '\033[36m%s\033[0m\n' "$*"; } +ok() { printf '\033[32m[OK] %s\033[0m\n' "$*"; } +warn() { printf '\033[33m[WARNING] %s\033[0m\n' "$*" >&2; } +err() { printf '\033[31m[ERROR] %s\033[0m\n' "$*" >&2; exit 1; } + +usage() { sed -n '2,3p' "${BASH_SOURCE[0]}"; } -usage() { - sed -n '2,20p' "${BASH_SOURCE[0]}" - exit 0 +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --full) MODE="full" ;; + --version) + [ "$#" -ge 2 ] || err "--version requires a value" + VERSION="$2"; shift + ;; + --version=*) VERSION="${1#*=}" ;; + --dir) + [ "$#" -ge 2 ] || err "--dir requires a value" + INSTALL_DIR="$2"; shift + ;; + --dir=*) INSTALL_DIR="${1#*=}" ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; err "Unknown option: $1" ;; + esac + shift + done } -while [ $# -gt 0 ]; do - case "$1" in - --full) MODE="full" ;; - --version) VERSION="$2"; shift ;; - --version=*) VERSION="${1#*=}" ;; - --dir) INSTALL_DIR="$2"; shift ;; - --dir=*) INSTALL_DIR="${1#*=}" ;; - -h|--help) usage ;; - *) echo "Unknown option: $1" >&2; usage ;; - esac - shift -done +cleanup() { + if [ -n "$TEMP_ROOT" ] && [ -d "$TEMP_ROOT" ]; then + rm -rf -- "$TEMP_ROOT" + fi +} +trap cleanup EXIT HUP INT TERM -info() { printf "\033[36m%s\033[0m\n" "$*"; } -ok() { printf "\033[32m[OK] %s\033[0m\n" "$*"; } -warn() { printf "\033[33m[WARNING] %s\033[0m\n" "$*"; } -err() { printf "\033[31m[ERROR] %s\033[0m\n" "$*" >&2; exit 1; } +make_temp_dir() { + [ -n "$TEMP_ROOT" ] && return + TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/scanforge.XXXXXXXX")" \ + || err "Unable to create a secure temporary directory" +} +# shellcheck disable=SC2120 # called with and without args; default uses uname detect_os() { - case "$(uname -s)" in - Linux*) OS="linux" ;; + local kernel="${1:-${SCANFORGE_UNAME_S:-$(uname -s)}}" + case "$kernel" in + Linux*) OS="linux" ;; Darwin*) OS="darwin" ;; MINGW*|MSYS*|CYGWIN*) OS="windows" ;; - *) err "Unsupported operating system: $(uname -s)" ;; + *) err "Unsupported operating system: ${kernel}" ;; esac } +# shellcheck disable=SC2120 # called with and without args; default uses uname detect_arch() { - case "$(uname -m)" in - x86_64|amd64) ARCH="amd64" ;; - aarch64|arm64) ARCH="arm64" ;; - i386|i686) warn "No 386 builds published, falling back to amd64" ; ARCH="amd64" ;; - *) warn "Unsupported architecture $(uname -m), trying amd64" ; ARCH="amd64" ;; + local machine="${1:-${SCANFORGE_UNAME_M:-$(uname -m)}}" + case "$machine" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + i386|i486|i586|i686|x86) + err "Unsupported 32-bit architecture: ${machine}; ScanForge publishes no 386 build" + ;; + *) err "Unsupported architecture: ${machine}; no compatible ScanForge build is published" ;; esac + if [ "$OS" = "windows" ] && [ "$ARCH" != "amd64" ]; then + err "Unsupported Windows architecture: ${machine}; only windows/amd64 is published" + fi +} + +detect_package_manager() { + PACKAGE_MANAGER="none" + if [ "$OS" = "linux" ] && command -v pacman >/dev/null 2>&1; then + PACKAGE_MANAGER="pacman" + elif [ "$OS" = "linux" ] && command -v apt-get >/dev/null 2>&1; then + PACKAGE_MANAGER="apt" + elif [ "$OS" = "darwin" ] && command -v brew >/dev/null 2>&1; then + PACKAGE_MANAGER="brew" + fi } resolve_version() { + command -v curl >/dev/null 2>&1 || err "curl is required for installation" if [ "$VERSION" = "latest" ]; then + local release_json tag info "Fetching the latest available version..." - TAG="$(curl -fsSL "${API_BASE}/releases/latest" 2>/dev/null \ - | grep -o '"tag_name": *"[^"]*"' \ - | head -1 \ - | sed -E 's/.*"([^"]*)"$/\1/')" || true - [ -n "$TAG" ] || err "Unable to determine the latest version" - VERSION="${TAG#v}" + release_json="$(curl -fsSL "${API_BASE}/releases/latest")" \ + || err "Unable to query the latest GitHub release" + tag="$(printf '%s' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + [ -n "$tag" ] || err "Unable to determine the latest release version" + VERSION="${tag#v}" else VERSION="${VERSION#v}" fi + case "$VERSION" in ''|*[!0-9A-Za-z._+-]*) err "Invalid version: ${VERSION}" ;; esac info "Target version: ${VERSION}" } file_sha256() { if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | cut -d' ' -f1 + sha256sum "$1" | awk '{print tolower($1)}' elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$1" | cut -d' ' -f1 + shasum -a 256 "$1" | awk '{print tolower($1)}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$1" | awk '{print tolower($NF)}' else return 1 fi } -install_scanforge() { - command -v curl >/dev/null 2>&1 || err "curl is required for installation" - - detect_os - detect_arch - resolve_version - - ARCHIVE_EXT="tar.gz" - BIN_NAME="scanforge" - if [ "$OS" = "windows" ]; then - ARCHIVE_EXT="zip" - BIN_NAME="scanforge.exe" - fi - - ASSET="scanforge_${VERSION}_${OS}_${ARCH}.${ARCHIVE_EXT}" - URL="https://github.com/${REPO}/releases/download/v${VERSION}/${ASSET}" - - DEST="${INSTALL_DIR:-${SCANFORGE_INSTALL_DIR:-}}" - if [ -z "$DEST" ]; then - DEST="${HOME}/.local/bin" - fi - mkdir -p "$DEST" - - TMPDIR="$(mktemp -d)" - trap 'rm -rf "$TMPDIR"' EXIT +checksum_entry() { + local checksum_file="$1" artifact_name="$2" + awk -v name="$artifact_name" '{ file=$2; sub(/^\*/, "", file); if (file == name) { print tolower($1); found=1; exit } } END { if (!found) exit 1 }' "$checksum_file" +} - info "Downloading ${ASSET} ..." - if ! curl -fsSL "$URL" -o "${TMPDIR}/${ASSET}"; then - err "Asset not found for version v${VERSION}. See https://github.com/${REPO}/releases" - fi +verify_checksum() { + local file="$1" expected="$2" label="$3" actual normalized + normalized="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')" + case "$normalized" in *[!0-9a-f]*) err "Invalid SHA-256 value for ${label}" ;; esac + [ "${#normalized}" -eq 64 ] || err "Invalid SHA-256 length for ${label}" + actual="$(file_sha256 "$file")" \ + || err "Cannot verify ${label}: install sha256sum, shasum, or openssl" + [ "$actual" = "$normalized" ] \ + || err "SHA-256 mismatch for ${label}: expected ${normalized}, got ${actual}" + ok "SHA-256 verified (${label})" +} - # Extract the archive - case "$ARCHIVE_EXT" in - tar.gz) - tar -xzf "${TMPDIR}/${ASSET}" -C "$TMPDIR" ;; +validate_archive_members() { + local archive="$1" extension="$2" entry list_file + make_temp_dir + list_file="${TEMP_ROOT}/archive-members.txt" + case "$extension" in + tar.gz) tar -tzf "$archive" > "$list_file" || err "Invalid tar archive" ;; zip) if command -v unzip >/dev/null 2>&1; then - unzip -qo "${TMPDIR}/${ASSET}" -d "$TMPDIR" + unzip -Z1 "$archive" > "$list_file" || err "Invalid zip archive" elif command -v python3 >/dev/null 2>&1; then - python3 -m zipfile -e "${TMPDIR}/${ASSET}" "$TMPDIR" + python3 -m zipfile -l "$archive" | awk 'NR > 2 && NF {print $NF}' > "$list_file" else - err "No zip extraction tool available (unzip or python3 required)" + err "unzip or python3 is required to inspect zip archives" fi ;; esac + while IFS= read -r entry; do + case "$entry" in /*|../*|*/../*|*/..) err "Unsafe path in release archive: ${entry}" ;; esac + done < "$list_file" +} - # Locate the binary inside the archive - BIN="" - for CAND in "${TMPDIR}"/scanforge*; do - [ -f "$CAND" ] || continue - BASE="$(basename "$CAND")" - case "$BASE" in - *.sha256|*.txt|*.md|*.zip|*.gz) continue ;; - esac - if [ -z "$BIN" ] || [ "$BASE" = "$BIN_NAME" ]; then - BIN="$CAND" - fi - done - [ -n "$BIN" ] || err "Binary not found in the archive" - - # Verify the SHA-256 checksum - SUM_FILE="$(find "$TMPDIR" -maxdepth 1 -name '*.sha256' | head -1)" - EXPECTED="" - if [ -n "$SUM_FILE" ]; then - EXPECTED="$(cut -d' ' -f1 "$SUM_FILE")" - ACTUAL="$(file_sha256 "$BIN" || true)" - elif curl -fsSL "https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt" -o "${TMPDIR}/checksums.txt" 2>/dev/null; then - EXPECTED="$(awk -v n="${ASSET}" '$2 == n {print $1}' "${TMPDIR}/checksums.txt")" - ACTUAL="$(file_sha256 "${TMPDIR}/${ASSET}" || true)" - fi - if [ -n "$EXPECTED" ] && [ -n "$ACTUAL" ] && [ "$EXPECTED" = "$ACTUAL" ]; then - ok "SHA-256 checksum verified" +extract_archive() { + local archive="$1" extension="$2" destination="$3" + validate_archive_members "$archive" "$extension" + case "$extension" in + tar.gz) tar -xzf "$archive" -C "$destination" ;; + zip) + if command -v unzip >/dev/null 2>&1; then + unzip -qo "$archive" -d "$destination" + else + python3 -m zipfile -e "$archive" "$destination" + fi + ;; + esac +} + +install_scanforge() { + local extension="tar.gz" binary_name="scanforge" release_name asset url archive + local checksums expected binary embedded_checksum embedded_expected checksum_url http_code + # shellcheck disable=SC2119 # detect_* intentional: no args means auto-detect + detect_os + # shellcheck disable=SC2119 + detect_arch + resolve_version + make_temp_dir + if [ "$OS" = "windows" ]; then extension="zip"; binary_name="scanforge.exe"; fi + release_name="scanforge_${VERSION}_${OS}_${ARCH}" + asset="${release_name}.${extension}" + url="https://github.com/${REPO}/releases/download/v${VERSION}/${asset}" + archive="${TEMP_ROOT}/${asset}" + checksums="${TEMP_ROOT}/checksums.txt" + DEST="${INSTALL_DIR:-${HOME}/.local/bin}" + mkdir -p -- "$DEST" + + info "Downloading ${asset} ..." + curl -fsSL "$url" -o "$archive" || err "Release asset not found: ${url}" + checksum_url="https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt" + http_code="$(curl -sSL -w '%{http_code}' "$checksum_url" -o "$checksums")" \ + || err "Unable to download release checksums from ${checksum_url}" + case "$http_code" in + 200) + expected="$(checksum_entry "$checksums" "$asset")" \ + || err "checksums.txt exists but has no entry for ${asset}" + verify_checksum "$archive" "$expected" "release archive ${asset}" + ;; + 404) warn "Release v${VERSION} has no checksums.txt; integrity verification is unavailable for this legacy release" ;; + *) err "Unable to download release checksums: HTTP ${http_code}" ;; + esac + + extract_archive "$archive" "$extension" "$TEMP_ROOT" + binary="${TEMP_ROOT}/${release_name}" + [ "$OS" = "windows" ] && binary="${binary}.exe" + [ -f "$binary" ] || err "Expected binary not found in archive: $(basename "$binary")" + embedded_checksum="${TEMP_ROOT}/${release_name}.sha256" + if [ -f "$embedded_checksum" ]; then + embedded_expected="$(checksum_entry "$embedded_checksum" "$(basename "$binary")")" \ + || err "Embedded checksum does not name $(basename "$binary")" + verify_checksum "$binary" "$embedded_expected" "extracted binary $(basename "$binary")" else - warn "Unable to verify the SHA-256 checksum" + warn "Archive contains no binary checksum; archive checksum was the only integrity check" fi - - chmod +x "$BIN" - mv -f "$BIN" "${DEST}/${BIN_NAME}" - ok "ScanForge ${VERSION} installed in ${DEST}/${BIN_NAME}" + chmod +x "$binary" + mv -f -- "$binary" "${DEST}/${binary_name}" + ok "ScanForge ${VERSION} installed in ${DEST}/${binary_name}" } -install_full() { - # .tools-version: local when the repo is cloned, otherwise fetched from GitHub - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo /tmp)" - if [ -f "${SCRIPT_DIR}/.tools-version" ]; then - TOOLS_FILE="${SCRIPT_DIR}/.tools-version" +load_tool_versions() { + local script_dir line key value + make_temp_dir + # shellcheck disable=SC2015 # cd failure should still fallback to true + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" + if [ -n "$script_dir" ] && [ -f "${script_dir}/.tools-version" ]; then + TOOLS_FILE="${script_dir}/.tools-version" else + TOOLS_FILE="${TEMP_ROOT}/tools-version" info "Fetching pinned tool versions (.tools-version)..." - TOOLS_FILE="/tmp/scanforge-tools-version" - curl -fsSL "${RAW_BASE}/.tools-version" -o "$TOOLS_FILE" \ - || err "Unable to fetch .tools-version" + curl -fsSL "${RAW_BASE}/.tools-version" -o "$TOOLS_FILE" || err "Unable to fetch .tools-version" fi - # shellcheck source=/dev/null - source "$TOOLS_FILE" - - command -v go >/dev/null 2>&1 || err "Go is not installed or missing from PATH (https://go.dev/dl/)" - ok "Go is installed: $(go version)" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|'#'*) continue ;; esac + case "$line" in [A-Z_]*=*) ;; *) err "Invalid entry in .tools-version: ${line}" ;; esac + key="${line%%=*}"; value="${line#*=}" + case "$key" in + SUBFINDER_VERSION|DNSX_VERSION|HTTPX_VERSION|NAABU_VERSION|KATANA_VERSION|NUCLEI_VERSION|TLSX_VERSION|GAU_VERSION|FFUF_VERSION|SHUFFLEDNS_VERSION|SECLISTS_VERSION|SECLISTS_DNS_SHA256|MASSDNS_VERSION|MASSDNS_SOURCE_SHA256|WAFW00F_VERSION) ;; + *) err "Unknown key in .tools-version: ${key}" ;; + esac + printf -v "$key" '%s' "$value" + done < "$TOOLS_FILE" + for key in SUBFINDER_VERSION DNSX_VERSION HTTPX_VERSION NAABU_VERSION KATANA_VERSION NUCLEI_VERSION TLSX_VERSION GAU_VERSION FFUF_VERSION SHUFFLEDNS_VERSION SECLISTS_VERSION SECLISTS_DNS_SHA256 MASSDNS_VERSION MASSDNS_SOURCE_SHA256 WAFW00F_VERSION; do + [ -n "${!key:-}" ] || err "Missing ${key} in .tools-version" + done +} - # System packages (non-Go tools) - if command -v apt >/dev/null 2>&1; then - info "Installing system packages (nmap, python3, whatweb, wafw00f)..." - sudo apt update - sudo apt install -y nmap python3 python3-pip whatweb wafw00f massdns - elif command -v brew >/dev/null 2>&1; then - info "Installing system packages via Homebrew (nmap, whatweb, python3)..." - brew install nmap whatweb python3 massdns - pip3 install --user wafw00f || true - else - warn "Neither apt nor brew found. Install manually: nmap, python3, whatweb, wafw00f (pip install wafw00f)" +root_command() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else err "Root privileges are required for system packages, but sudo is unavailable" fi +} + +packages_for_manager() { + case "$1" in + pacman) printf '%s\n' nmap chromium go python-pipx base-devel ;; + apt) printf '%s\n' nmap python3 python3-venv pipx whatweb chromium build-essential ;; + brew) printf '%s\n' nmap massdns go pipx ;; + esac +} + +install_system_packages() { + local candidate + local -a packages=() available=() + detect_package_manager + while IFS= read -r candidate; do [ -n "$candidate" ] && packages+=("$candidate"); done < <(packages_for_manager "$PACKAGE_MANAGER") + case "$PACKAGE_MANAGER" in + pacman) + info "Installing official Arch packages with pacman (no system upgrade)..." + root_command pacman -S --needed --noconfirm "${packages[@]}" + ;; + apt) + info "Refreshing apt metadata and installing available system dependencies..." + root_command apt-get update + for candidate in "${packages[@]}"; do + if apt-cache show "$candidate" >/dev/null 2>&1; then available+=("$candidate") + else warn "apt package unavailable on this release: ${candidate}" + fi + done + [ "${#available[@]}" -eq 0 ] || root_command apt-get install -y --no-install-recommends "${available[@]}" + ;; + brew) info "Installing Homebrew formulae..."; brew install "${packages[@]}" ;; + none) warn "No supported package manager found; system dependencies must be installed manually" ;; + esac +} - TOOLS=( +install_go_tools() { + local tool + local -a tools=( "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@${SUBFINDER_VERSION}" "github.com/projectdiscovery/dnsx/cmd/dnsx@${DNSX_VERSION}" "github.com/projectdiscovery/httpx/cmd/httpx@${HTTPX_VERSION}" @@ -214,28 +309,108 @@ install_full() { "github.com/ffuf/ffuf/v2@${FFUF_VERSION}" "github.com/projectdiscovery/shuffledns/cmd/shuffledns@${SHUFFLEDNS_VERSION}" ) + command -v go >/dev/null 2>&1 || err "Go is required for --full; see https://go.dev/dl/" + ok "Go is installed: $(go version)" + info "Installing Go tools at pinned versions..." + for tool in "${tools[@]}"; do info "Installing ${tool}"; go install "$tool"; done +} + +install_python_tools() { + if command -v pipx >/dev/null 2>&1; then + info "Installing wafw00f in an isolated pipx environment..." + pipx install --force "wafw00f==${WAFW00F_VERSION#v}" + elif command -v wafw00f >/dev/null 2>&1; then + warn "wafw00f exists but pipx is unavailable, so its pinned version could not be enforced" + else warn "pipx is unavailable; install wafw00f manually with pipx (global pip is intentionally not used)" + fi +} + +install_dns_wordlist() { + local data_home target downloaded url + data_home="${XDG_DATA_HOME:-${HOME}/.local/share}" + target="${data_home}/scanforge/wordlists/subdomains-top1million-5000.txt" + downloaded="${TEMP_ROOT}/subdomains-top1million-5000.txt" + url="https://raw.githubusercontent.com/danielmiessler/SecLists/${SECLISTS_VERSION}/Discovery/DNS/subdomains-top1million-5000.txt" + info "Downloading the pinned SecLists DNS wordlist..." + curl -fsSL "$url" -o "$downloaded" || err "Unable to download the SecLists DNS wordlist" + verify_checksum "$downloaded" "$SECLISTS_DNS_SHA256" "SecLists DNS wordlist ${SECLISTS_VERSION}" + mkdir -p -- "$(dirname "$target")" + cp -- "$downloaded" "$target" + ok "DNS wordlist installed in ${target}" +} + +install_massdns() { + local archive source_dir version + command -v massdns >/dev/null 2>&1 && { ok "massdns is already installed"; return; } + command -v make >/dev/null 2>&1 || { warn "make is unavailable; massdns remains manual"; return; } + command -v cc >/dev/null 2>&1 || { warn "a C compiler is unavailable; massdns remains manual"; return; } + version="${MASSDNS_VERSION#v}" + archive="${TEMP_ROOT}/massdns-${version}.tar.gz" + info "Downloading massdns ${MASSDNS_VERSION} source..." + curl -fsSL "https://github.com/blechschmidt/massdns/archive/refs/tags/${MASSDNS_VERSION}.tar.gz" -o "$archive" \ + || err "Unable to download massdns source" + verify_checksum "$archive" "$MASSDNS_SOURCE_SHA256" "massdns source archive ${MASSDNS_VERSION}" + validate_archive_members "$archive" tar.gz + tar -xzf "$archive" -C "$TEMP_ROOT" + source_dir="${TEMP_ROOT}/massdns-${version}" + make -C "$source_dir" + mkdir -p -- "${HOME}/.local/bin" + cp -- "${source_dir}/bin/massdns" "${HOME}/.local/bin/massdns" + chmod +x "${HOME}/.local/bin/massdns" + ok "massdns installed in ${HOME}/.local/bin/massdns" +} + +has_browser() { + command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || + command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1 || + [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ] || + [ -x "/Applications/Chromium.app/Contents/MacOS/Chromium" ] +} - info "Installing Go tools (pinned versions)... This may take a few minutes." - for TOOL in "${TOOLS[@]}"; do - echo "-> Installing ${TOOL} ..." - go install "$TOOL" - ok "Installed" +verify_full_install() { + local tool missing=0 + local -a required=(subfinder shuffledns dnsx httpx naabu nmap whatweb wafw00f katana ffuf nuclei gau tlsx massdns) + info "Verifying external dependencies..." + for tool in "${required[@]}"; do + if command -v "$tool" >/dev/null 2>&1; then ok "${tool}: installed" + else warn "${tool}: missing"; missing=1 + fi done + if has_browser; then ok "chromium/chrome: installed" + else warn "chromium/chrome: missing (optional, used by jsverify)" + fi + case "$PACKAGE_MANAGER" in + pacman) warn "Arch manual/AUR-only item: whatweb (massdns and the DNS wordlist were installed from verified upstream artifacts)" ;; + brew) warn "macOS manual item: whatweb; install a Chrome-family browser for jsverify if desired" ;; + none) warn "Install missing tools using platform packages, upstream releases, or Docker" ;; + esac + [ "$missing" -eq 0 ] || warn "--full completed with manual dependencies; run 'scanforge doctor --profile ' for profile-specific guidance" +} - info "Installing the ScanForge binary..." +install_full() { + # shellcheck disable=SC2119 + detect_os + [ "$OS" != "windows" ] || err "Use install.ps1 -Full on Windows" + load_tool_versions + install_system_packages + install_go_tools + install_python_tools + install_dns_wordlist + install_massdns install_scanforge + verify_full_install } -case "$MODE" in - binary) install_scanforge ;; - full) install_full ;; -esac +main() { + parse_args "$@" + case "$MODE" in binary) install_scanforge ;; full) install_full ;; esac + printf '\n'; info "Installation complete" + if ! command -v scanforge >/dev/null 2>&1; then + warn "Add the installation directories to PATH if necessary:" + # shellcheck disable=SC2016 # $PATH must remain literal for the user's shell. + printf ' export PATH="%s:%s/bin:$PATH"\n' "$DEST" "$(go env GOPATH 2>/dev/null || printf '%s/go' "$HOME")" + fi + printf ' scanforge init\n scanforge doctor\n' +} -echo "" -info "Installation complete!" -if ! command -v scanforge >/dev/null 2>&1; then - warn "Add the install directory to your PATH:" - echo " export PATH=\"${DEST}:${PATH}\"" -fi -echo " scanforge init" -echo " scanforge doctor" +if [ "${SCANFORGE_INSTALLER_TESTING:-0}" != "1" ]; then main "$@"; fi diff --git a/internal/app/events.go b/internal/app/events.go index 47ec482..9af388d 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -25,17 +25,23 @@ import ( // the TUI itself failed and the scan was aborted. func (s *runSession) consumeEvents(cancel context.CancelFunc, eventChan <-chan orchestrator.Event, done <-chan struct{}) error { if !s.opts.DryRun && term.IsTerminal(int(os.Stdout.Fd())) { - model := tui.NewScanModel(eventChan) - if _, err := tea.NewProgram(model).Run(); err != nil { + model := tui.NewScanModel(eventChan, s.opts.Target, s.profile) + p := tea.NewProgram(model, tea.WithAltScreen()) + finalModel, err := p.Run() + if err != nil { cancel() drainEvents(eventChan) <-done return err } // Replay warnings collected in the scan view; once the TUI is gone - // nothing else would surface them. - for _, warning := range model.Warnings() { - ui.Warn("%s", warning) + // nothing else would surface them. The final model is the one that + // actually received events during the run — the original model value + // was copied when passed to the program. + if scanModel, ok := finalModel.(tui.ScanModel); ok { + for _, warning := range scanModel.Warnings() { + ui.Warn("%s", warning) + } } // The user may have quit the UI before the scan finished: cancel the // run and drain the remaining events so the orchestrator can return. diff --git a/internal/app/output.go b/internal/app/output.go index 3377405..3d6e52c 100644 --- a/internal/app/output.go +++ b/internal/app/output.go @@ -51,8 +51,8 @@ func printRunInfoPanel(opts RunOptions, profile string, scanRun *storage.Run, ef } var b strings.Builder - fmt.Fprintf(&b, "%s\n", kv("TARGET", ui.Primary(opts.Target))) - fmt.Fprintf(&b, "%s\n", kv("PROFILE", ui.Secondary(profile))) + fmt.Fprintf(&b, "%s\n", kv("TARGET", ui.AccentBold(opts.Target))) + fmt.Fprintf(&b, "%s\n", kv("PROFILE", ui.Primary(profile))) fmt.Fprintf(&b, "%s\n", kv("SCOPE", ui.Dim(fmt.Sprintf("%s (%s, mode %s)", scanRun.Manifest.ScopePath, effective.proposal.Source, effective.proposal.Mode)))) fmt.Fprintf(&b, "%s\n", kv("DRY RUN", dryTag)) fmt.Fprintf(&b, "%s", kv("OUTPUT", ui.Dim(scanRun.RootDir))) diff --git a/internal/ascii/ascii.go b/internal/ascii/ascii.go index 2894bdc..2ee099d 100644 --- a/internal/ascii/ascii.go +++ b/internal/ascii/ascii.go @@ -3,6 +3,8 @@ package ascii import ( "fmt" + "math/rand" + "os" "strings" "github.com/MikeRoss27/scanforge/internal/ui" @@ -20,15 +22,15 @@ const ( ███ ███ █▄ ███ ███ ███ ███ ███ ███ ███ ▀███████████ ███ ███ ███ █▄ ▄█ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ▄████████▀ ████████▀ ███ █▀ ▀█ █▀ ███ ▀██████▀ ███ ███ ████████▀ ██████████ - ███ ███` + ███ ███` // Style 2: classic slant, very readable BannerClassic = ` - _____ ______ - / ___/_________ _____ / ____/___ _________ ____ - \__ \/ ___/ __ '/ __ \/ /_ / __ \/ ___/ __ '/ _ \ - ___/ / /__/ /_/ / / / / __/ / /_/ / / / /_/ / __/ -/____/\___/\__,_/_/ /_/_/ \____/_/ \__, /\___/ + _____ ______ + / ___/_________ _____ / ____/___ _________ ____ + \__ \/ ___/ __ '/ __ \/ /_ / __ \/ ___/ __ '/ _ \ + ___/ / /__/ /_/ / / / / __/ / /_/ / / / /_/ / __/ +/____/\___/\__,_/_/ /_/_/ \____/_/ \__, /\___/ /____/ ` // Style 3: 3D shaded (from ascii.txt) @@ -42,17 +44,76 @@ $$ \ $$ | $$ $$ |$$$$ $$ |$$ | $$ | $$ |$$ $$< $$ |/ |$ / \__$$ |$$ \__/ |$$ | $$ |$$ |$$$$ |$$ | $$ \__$$ |$$ | $$ |$$ \__$$ |$$ |_____ $$ $$/ $$ $$/ $$ | $$ |$$ | $$$ |$$ | $$ $$/ $$ | $$ |$$ $$/ $$ | $$$$$$/ $$$$$$/ $$/ $$/ $$/ $$/ $$/ $$$$$$/ $$/ $$/ $$$$$$/ $$$$$$$$/` + + // Style 4: Skull + BannerSkull = ` + . . + .n . . n. + . .dP dP 9b 9b. . + 4 qXb . dX Xb . dXp t + dX. 9Xb .dXb __ __ dXb. dXP .Xb + 9XXb._ _.dXXXXb dXXXXbo. .odXXXXb dXXXXb._ _.dXXP + 9XXXXXXXXXXXXXXXXXXXVXXXXXXXXOo. .oOXXXXXXXXVXXXXXXXXXXXXXXXXXXXP + 9XXXXXXXXXXXXXXXXXXXXX'~ ~'OOO8b d8OOO'~ ~'XXXXXXXXXXXXXXXXXXXXXP' + 9XXXXXXXXXXXP' '9XX' '98v8P' 'XXP' '9XXXXXXXXXXXP' + ~~~~~~~ 9X. .db|db. . .db|db. .XP ~~~~~~~ + )b. .dbo.dP''b dP''bo.db. .dX( + ,dXXXXXXXXXXXb dXXXXXXXXXXXb. + dXXXXXXXXXXXP' . '9XXXXXXXXXXXb + dXXXXXXXXXXXXb d|b dXXXXXXXXXXXXb + 9XXb' 'XXXXXb.dX|Xb.dXXXXX' 'dXXP + ' 9XXXXXX( )XXXXXXP ' + XXXX X.' '.X XXXX + XP^X'b d'X^XX + X. 9 ' ' P )X + 'b ' ' d' + ' ' + By MikeRoss +` ) -// PrintBanner prints the "Blocks" banner with a constant cyan → magenta -// gradient, for a consistent visual identity on every run (random banner and -// gradient selection made the branding unstable from one run to the next). +// PrintBanner prints one of the 4 banners. By default it picks randomly +// among Blocks/Classic/Slanted/Skull so l'ascii art change à chaque run. +// Pour figer un style : SCANFORGE_BANNER=blocks|classic|slanted|skull|off +// (ex: SCANFORGE_BANNER=classic ./scanforge run ...). Sans env, random. +// Le rendu reste monochrome bleu calme (ui.Primary) — plus de dégradé +// violet/rose. Historiquement le code tirait au hasard parmi 3 banners avec +// 3 gradients (a18501d), puis a été figé sur Blocks + gradient cyan→magenta +// pour "branding stable" — d'où l'impression que "ça ne change jamais" +// alors que 4 const existent mais une seule était utilisée. func PrintBanner() { - for _, line := range strings.Split(BannerBlocks, "\n") { + style := strings.ToLower(strings.TrimSpace(os.Getenv("SCANFORGE_BANNER"))) + var banner string + switch style { + case "blocks": + banner = BannerBlocks + case "classic": + banner = BannerClassic + case "slanted": + banner = BannerSlanted + case "skull": + banner = BannerSkull + case "off", "none", "0", "false": + return + case "random", "": + banners := []string{BannerBlocks, BannerClassic, BannerSlanted, BannerSkull} + banner = banners[rand.Intn(len(banners))] + default: + // valeur inconnue -> random par défaut + banners := []string{BannerBlocks, BannerClassic, BannerSlanted, BannerSkull} + banner = banners[rand.Intn(len(banners))] + } + + for _, line := range strings.Split(banner, "\n") { if strings.TrimSpace(line) == "" { continue } - fmt.Println(ui.Gradient(line, ui.AccentCyan, ui.AccentMagenta)) + fmt.Println(ui.Primary(line)) } fmt.Println() } + +// BannerNames returns the list of valid SCANFORGE_BANNER values. +func BannerNames() []string { + return []string{"blocks", "classic", "slanted", "skull", "random", "off"} +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 984da91..571f757 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -69,8 +69,8 @@ func printPlanPanel(out io.Writer, plan *app.PlanResult) { } var info strings.Builder - fmt.Fprintf(&info, "%s\n", kv("TARGET", ui.Primary(plan.Target))) - fmt.Fprintf(&info, "%s\n", kv("PROFILE", ui.Secondary(plan.Profile))) + fmt.Fprintf(&info, "%s\n", kv("TARGET", ui.AccentBold(plan.Target))) + fmt.Fprintf(&info, "%s\n", kv("PROFILE", ui.Primary(plan.Profile))) fmt.Fprintf(&info, "%s\n", kv("SCOPE", ui.Dim(plan.Scope))) fmt.Fprintf(&info, "%s\n", kv("SOURCE", ui.Dim(fmt.Sprintf("%s (mode %s)", plan.ScopeSource, plan.ScopeMode)))) for _, entry := range plan.ScopeEntries { diff --git a/internal/config/config.go b/internal/config/config.go index 3b573bd..95f1a2a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -201,6 +201,7 @@ tools: nuclei: nuclei gau: gau tlsx: tlsx + shuffledns: shuffledns chromium: chromium # overrides for built-in profiles (safe, recon, web, ports, vuln, deep, full) @@ -279,6 +280,9 @@ func mergeDefaults(base, parsed *Config) { if parsed.Tools.Shuffledns == "" { parsed.Tools.Shuffledns = base.Tools.Shuffledns } + if parsed.Tools.Chromium == "" { + parsed.Tools.Chromium = base.Tools.Chromium + } if len(parsed.Profiles) == 0 { parsed.Profiles = base.Profiles } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index cb1a11d..8298f76 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -23,18 +23,20 @@ func Default() *Config { DefaultProfile: DefaultProfile, DefaultScope: DefaultScope, Tools: Tools{ - Subfinder: "subfinder", - Dnsx: "dnsx", - Httpx: "httpx", - Naabu: "naabu", - Nmap: "nmap", - Whatweb: "whatweb", - Wafw00f: "wafw00f", - Katana: "katana", - Ffuf: "ffuf", - Nuclei: "nuclei", - Gau: "gau", - Tlsx: "tlsx", + Subfinder: "subfinder", + Dnsx: "dnsx", + Httpx: "httpx", + Naabu: "naabu", + Nmap: "nmap", + Whatweb: "whatweb", + Wafw00f: "wafw00f", + Katana: "katana", + Ffuf: "ffuf", + Nuclei: "nuclei", + Gau: "gau", + Tlsx: "tlsx", + Shuffledns: "shuffledns", + Chromium: "chromium", }, Profiles: map[string][]string{}, ModuleTimeouts: map[string]time.Duration{}, diff --git a/internal/dependencies/catalog.go b/internal/dependencies/catalog.go new file mode 100644 index 0000000..a988fee --- /dev/null +++ b/internal/dependencies/catalog.go @@ -0,0 +1,160 @@ +// Package dependencies describes external runtime tools in one place for +// profile-aware diagnostics. Installation scripts remain platform-native, but +// their tool list is validated against this catalog in CI. +package dependencies + +import ( + "os" + "runtime" + "strings" +) + +// PinnedVersions is populated from .tools-version through build ldflags. +// Local development builds may leave it empty; version checks then remain +// informational instead of pretending to know an expected version. +var PinnedVersions string + +type Dependency struct { + Name string + Binary string + Modules []string + Optional bool + VersionKey string + GoPackage string + Compare bool +} + +var catalog = []Dependency{ + {Name: "subfinder", Binary: "subfinder", Modules: []string{"subfinder"}, VersionKey: "SUBFINDER_VERSION", GoPackage: "github.com/projectdiscovery/subfinder/v2/cmd/subfinder", Compare: true}, + {Name: "shuffledns", Binary: "shuffledns", Modules: []string{"dnsbrute"}, VersionKey: "SHUFFLEDNS_VERSION", GoPackage: "github.com/projectdiscovery/shuffledns/cmd/shuffledns", Compare: true}, + {Name: "massdns", Binary: "massdns", Modules: []string{"dnsbrute"}}, + {Name: "dnsx", Binary: "dnsx", Modules: []string{"dnsx"}, VersionKey: "DNSX_VERSION", GoPackage: "github.com/projectdiscovery/dnsx/cmd/dnsx", Compare: true}, + {Name: "httpx", Binary: "httpx", Modules: []string{"httpx", "screenshot"}, VersionKey: "HTTPX_VERSION", GoPackage: "github.com/projectdiscovery/httpx/cmd/httpx", Compare: true}, + {Name: "naabu", Binary: "naabu", Modules: []string{"naabu"}, VersionKey: "NAABU_VERSION", GoPackage: "github.com/projectdiscovery/naabu/v2/cmd/naabu", Compare: true}, + {Name: "nmap", Binary: "nmap", Modules: []string{"nmap"}}, + {Name: "whatweb", Binary: "whatweb", Modules: []string{"whatweb"}}, + {Name: "wafw00f", Binary: "wafw00f", Modules: []string{"wafw00f"}, VersionKey: "WAFW00F_VERSION", Compare: true}, + {Name: "katana", Binary: "katana", Modules: []string{"katana"}, VersionKey: "KATANA_VERSION", GoPackage: "github.com/projectdiscovery/katana/cmd/katana", Compare: true}, + {Name: "chromium", Binary: "chromium", Modules: []string{"jsverify"}, Optional: true}, + {Name: "ffuf", Binary: "ffuf", Modules: []string{"ffuf"}, VersionKey: "FFUF_VERSION", GoPackage: "github.com/ffuf/ffuf/v2", Compare: true}, + {Name: "nuclei", Binary: "nuclei", Modules: []string{"nuclei"}, VersionKey: "NUCLEI_VERSION", GoPackage: "github.com/projectdiscovery/nuclei/v3/cmd/nuclei", Compare: true}, + {Name: "gau", Binary: "gau", Modules: []string{"gau"}, VersionKey: "GAU_VERSION", GoPackage: "github.com/lc/gau/v2/cmd/gau", Compare: true}, + {Name: "tlsx", Binary: "tlsx", Modules: []string{"tlsx"}, VersionKey: "TLSX_VERSION", GoPackage: "github.com/projectdiscovery/tlsx/cmd/tlsx", Compare: true}, +} + +var DNSWordlistCandidates = []string{ + "/usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt", + "/usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt", + "/usr/share/seclists/Discovery/DNS/namelist.txt", + "/opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt", + "/usr/share/wordlists/amass/subdomains.lst", + "/usr/share/scanforge/wordlists/subdomains-top1million-5000.txt", +} + +func DNSWordlistPaths() []string { + paths := make([]string, 0, len(DNSWordlistCandidates)+2) + if explicit := os.Getenv("SCANFORGE_DNS_WORDLIST"); explicit != "" { + paths = append(paths, explicit) + } + if runtime.GOOS == "windows" { + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + paths = append(paths, localAppData+`\scanforge\wordlists\subdomains-top1million-5000.txt`) + } + } else if home, err := os.UserHomeDir(); err == nil { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + dataHome = home + "/.local/share" + } + paths = append(paths, dataHome+"/scanforge/wordlists/subdomains-top1million-5000.txt") + } + return append(paths, DNSWordlistCandidates...) +} + +func ForModules(modules []string) []Dependency { + selected := make(map[string]bool, len(modules)) + for _, module := range modules { + selected[module] = true + } + var result []Dependency + for _, dependency := range catalog { + for _, module := range dependency.Modules { + if selected[module] { + result = append(result, dependency) + break + } + } + } + return result +} + +func ExpectedVersion(key string) string { + for _, entry := range strings.Split(PinnedVersions, ",") { + parts := strings.SplitN(entry, "=", 2) + if len(parts) == 2 && parts[0] == key { + return strings.TrimPrefix(parts[1], "v") + } + } + return "" +} + +func InstallHint(dependency Dependency) string { + version := ExpectedVersion(dependency.VersionKey) + wafw00fSpec := "wafw00f" + if version != "" { + wafw00fSpec += "==" + version + } + if dependency.GoPackage != "" { + if version == "" { + return "go install " + dependency.GoPackage + "@" + } + return "go install " + dependency.GoPackage + "@v" + version + } + + switch runtime.GOOS { + case "darwin": + switch dependency.Name { + case "nmap", "massdns": + return "brew install " + dependency.Name + case "wafw00f": + return "brew install pipx && pipx install " + wafw00fSpec + case "chromium": + return "install Google Chrome/Chromium and set tools.chromium if it is not on PATH" + case "whatweb": + return "install WhatWeb from upstream, or use Docker" + } + case "windows": + switch dependency.Name { + case "nmap": + return "use the official Nmap Windows installer" + case "wafw00f": + return "install pipx, then run: pipx install " + wafw00fSpec + case "whatweb", "massdns": + return "use WSL/Docker or install the upstream project manually" + case "chromium": + return "install Chrome/Chromium and configure tools.chromium" + } + default: + if _, err := os.Stat("/usr/bin/pacman"); err == nil { + switch dependency.Name { + case "nmap", "chromium": + return "sudo pacman -S --needed " + dependency.Name + case "wafw00f": + return "sudo pacman -S --needed python-pipx && pipx install " + wafw00fSpec + case "whatweb": + return "not in official Arch repos; review the AUR PKGBUILD, install upstream manually, or use Docker" + case "massdns": + return "rerun install.sh --full (verified upstream build); an AUR package also exists" + } + } else { + switch dependency.Name { + case "nmap", "whatweb", "chromium": + return "sudo apt-get install " + dependency.Name + " (when available on this release)" + case "wafw00f": + return "sudo apt-get install pipx && pipx install " + wafw00fSpec + case "massdns": + return "rerun install.sh --full for the verified upstream build" + } + } + } + return "install it with the platform package manager or from upstream" +} diff --git a/internal/dependencies/catalog_test.go b/internal/dependencies/catalog_test.go new file mode 100644 index 0000000..21c12d0 --- /dev/null +++ b/internal/dependencies/catalog_test.go @@ -0,0 +1,28 @@ +package dependencies + +import "testing" + +func TestForModulesMapsSharedAndTransitiveDependencies(t *testing.T) { + dependencies := ForModules([]string{"dnsbrute", "screenshot", "jsverify"}) + seen := map[string]Dependency{} + for _, dependency := range dependencies { + seen[dependency.Name] = dependency + } + for _, name := range []string{"shuffledns", "massdns", "httpx", "chromium"} { + if _, ok := seen[name]; !ok { + t.Errorf("missing dependency %s", name) + } + } + if !seen["chromium"].Optional { + t.Error("chromium should be optional because jsverify degrades gracefully") + } +} + +func TestExpectedVersionUsesInjectedManifest(t *testing.T) { + previous := PinnedVersions + PinnedVersions = "SUBFINDER_VERSION=v2.15.0,DNSX_VERSION=v1.3.0" + t.Cleanup(func() { PinnedVersions = previous }) + if got := ExpectedVersion("SUBFINDER_VERSION"); got != "2.15.0" { + t.Fatalf("unexpected version: %q", got) + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 4d3b4f3..61b7e14 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -9,9 +9,11 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "github.com/MikeRoss27/scanforge/internal/config" + "github.com/MikeRoss27/scanforge/internal/dependencies" "github.com/MikeRoss27/scanforge/internal/ui" ) @@ -66,10 +68,24 @@ func (DefaultToolChecker) CheckTool(ctx context.Context, name, binary string, ve check.Status = SeverityOK version := extractVersionLine(versionOutput) + + goModVersion := "" + if name == "dnsx" || name == "tlsx" || name == "ffuf" { + goModVersion = goModVersionForBinary(ctx, path, goModulePathForTool(name)) + } + if verbose { - check.Message = fmt.Sprintf("%s (%s)", version, path) + if goModVersion != "" { + check.Message = fmt.Sprintf("%s (%s; tool reports %s)", goModVersion, path, version) + } else { + check.Message = fmt.Sprintf("%s (%s)", version, path) + } } else { - check.Message = version + if goModVersion != "" { + check.Message = goModVersion + } else { + check.Message = version + } if check.Message == "" { check.Message = path } @@ -78,6 +94,30 @@ func (DefaultToolChecker) CheckTool(ctx context.Context, name, binary string, ve return check } +func goModVersionForBinary(ctx context.Context, binaryPath, modulePath string) string { + if modulePath == "" { + return "" + } + goModOutput, err := runGoVersionCommand(ctx, binaryPath) + if err != nil { + return "" + } + return extractVersionFromGoMod(goModOutput, modulePath) +} + +func goModulePathForTool(name string) string { + switch name { + case "dnsx": + return "github.com/projectdiscovery/dnsx" + case "tlsx": + return "github.com/projectdiscovery/tlsx" + case "ffuf": + return "github.com/ffuf/ffuf/v2" + default: + return "" + } +} + type Runner struct { checker ToolChecker } @@ -100,46 +140,60 @@ func (r *Runner) Run(ctx context.Context, opts Options) ([]Check, int, error) { profile = cfg.DefaultProfile } - checks := make([]Check, 0, 8) + checks := make([]Check, 0, 20) moduleNames, err := cfg.ProfileModules(profile) if err != nil { - // fallback to empty set if profile is unknown - moduleNames = []string{} + return nil, 1, err } - moduleSet := make(map[string]bool) + moduleSet := make(map[string]bool, len(moduleNames)) for _, m := range moduleNames { moduleSet[m] = true } - requiredTools := []struct { - name string - binary string - }{ - {name: "subfinder", binary: cfg.ToolPath("subfinder")}, - {name: "dnsx", binary: cfg.ToolPath("dnsx")}, - {name: "httpx", binary: cfg.ToolPath("httpx")}, - {name: "naabu", binary: cfg.ToolPath("naabu")}, - {name: "nmap", binary: cfg.ToolPath("nmap")}, - {name: "whatweb", binary: cfg.ToolPath("whatweb")}, - {name: "wafw00f", binary: cfg.ToolPath("wafw00f")}, - {name: "katana", binary: cfg.ToolPath("katana")}, - {name: "ffuf", binary: cfg.ToolPath("ffuf")}, - {name: "nuclei", binary: cfg.ToolPath("nuclei")}, - {name: "gau", binary: cfg.ToolPath("gau")}, - {name: "tlsx", binary: cfg.ToolPath("tlsx")}, - {name: "shuffledns", binary: cfg.ToolPath("shuffledns")}, - } - - for _, tool := range requiredTools { - if len(moduleSet) > 0 && !moduleSet[tool.name] { - continue + for _, dependency := range dependencies.ForModules(moduleNames) { + binary := cfg.ToolPath(dependency.Binary) + if dependency.Name == "chromium" { + binary = resolveBrowserBinary(binary) + } + check := r.checker.CheckTool(ctx, dependency.Name, binary, opts.Verbose) + check.Required = !dependency.Optional + switch check.Status { + case SeverityFail: + if dependency.Optional { + check.Status = SeverityWarn + } + check.Message += "; install: " + dependencies.InstallHint(dependency) + case SeverityOK: + expected := "" + if dependency.Compare { + expected = dependencies.ExpectedVersion(dependency.VersionKey) + } + if expected != "" { + if dependency.Name == "dnsx" || dependency.Name == "tlsx" || dependency.Name == "ffuf" { + resolvedPath := binary + if p, err := exec.LookPath(binary); err == nil { + resolvedPath = p + } + modVersion := goModVersionForBinary(ctx, resolvedPath, goModulePathForTool(dependency.Name)) + if modVersion == "" { + // Go not available or binary without module info — skip strict + // comparison to avoid false positives from stale embedded version. + } else if !strings.EqualFold(modVersion, strings.TrimPrefix(expected, "v")) { + check.Status = SeverityWarn + check.Message += fmt.Sprintf("; expected v%s (pinned in .tools-version)", expected) + } + } else if !versionMatches(check.Message, expected) { + check.Status = SeverityWarn + check.Message += fmt.Sprintf("; expected v%s (pinned in .tools-version)", expected) + } + } } - - check := r.checker.CheckTool(ctx, tool.name, tool.binary, opts.Verbose) - check.Required = true checks = append(checks, check) } + if moduleSet["dnsbrute"] { + checks = append(checks, checkDNSWordlist()) + } checks = append(checks, checkWorkspace(cfg)) checks = append(checks, checkConfigFile()) @@ -155,6 +209,40 @@ func (r *Runner) Run(ctx context.Context, opts Options) ([]Check, int, error) { return checks, exitCode, nil } +func resolveBrowserBinary(configured string) string { + if configured != "" && configured != "chromium" { + return configured + } + for _, candidate := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome-headless-shell"} { + if path, err := exec.LookPath(candidate); err == nil { + return path + } + } + return configured +} + +var semanticVersion = regexp.MustCompile(`(?i)\bv?([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9a-z.-]+)?)\b`) +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +func versionMatches(message, expected string) bool { + match := semanticVersion.FindStringSubmatch(ansiEscape.ReplaceAllString(message, "")) + return len(match) > 1 && strings.EqualFold(match[1], strings.TrimPrefix(expected, "v")) +} + +func checkDNSWordlist() Check { + for _, candidate := range dependencies.DNSWordlistPaths() { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > 0 { + return Check{Name: "dns-wordlist", Status: SeverityOK, Message: candidate, Required: true} + } + } + return Check{ + Name: "dns-wordlist", + Status: SeverityFail, + Message: "no compatible wordlist found; rerun install.sh --full, install SecLists, or set SCANFORGE_DNS_WORDLIST", + Required: true, + } +} + func checkWorkspace(cfg *config.Config) Check { dir := config.WorkspaceDir(cfg) check := Check{ @@ -237,6 +325,8 @@ func checkScopeFile(cfg *config.Config) Check { func runVersionCommand(ctx context.Context, binary string) (string, error) { args := [][]string{ + {"--version"}, + {"-V"}, {"-version"}, {"-v"}, {"version"}, @@ -255,6 +345,35 @@ func runVersionCommand(ctx context.Context, binary string) (string, error) { return "", lastErr } +func runGoVersionCommand(ctx context.Context, binary string) (string, error) { + cmd := exec.CommandContext(ctx, "go", "version", "-m", binary) + output, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + return string(output), nil +} + +func extractVersionFromGoMod(output, modulePath string) string { + for _, raw := range strings.Split(output, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) >= 3 && fields[0] == "mod" && fields[1] == modulePath { + return strings.TrimPrefix(fields[2], "v") + } + if strings.HasPrefix(line, "mod\t"+modulePath+"\t") { + parts := strings.Split(line, "\t") + if len(parts) >= 3 { + return strings.TrimPrefix(parts[2], "v") + } + } + } + return "" +} + // extractVersionLine pulls the one meaningful line out of a tool's version // output. Many of the wrapped binaries (projectdiscovery's tools especially) // print a multi-line ASCII banner before the actual version, which would diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 71cfe92..69c13e0 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -34,7 +34,7 @@ func TestRunAllToolsOK(t *testing.T) { }) checks, exitCode, err := runner.Run(context.Background(), Options{ - Profile: "web", + Profile: "safe", Config: config.Default(), }) if err != nil { @@ -102,6 +102,73 @@ func TestRunPassiveSkipsNuclei(t *testing.T) { } } +func TestRunDNSBruteChecksTransitiveTools(t *testing.T) { + cfg := config.Default() + cfg.Profiles["dns-only"] = []string{"dnsbrute"} + runner := New(mockToolChecker{}) + + checks, _, err := runner.Run(context.Background(), Options{Profile: "dns-only", Config: cfg}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + seen := map[string]bool{} + for _, check := range checks { + seen[check.Name] = true + } + for _, name := range []string{"shuffledns", "massdns", "dns-wordlist"} { + if !seen[name] { + t.Errorf("expected %s to be checked for dnsbrute", name) + } + } +} + +func TestRunOptionalChromiumDoesNotFail(t *testing.T) { + cfg := config.Default() + cfg.Profiles["browser-only"] = []string{"jsverify"} + runner := New(mockToolChecker{results: map[string]Check{ + "chromium": {Name: "chromium", Status: SeverityFail, Message: "missing", Required: true}, + }}) + + checks, exitCode, err := runner.Run(context.Background(), Options{Profile: "browser-only", Config: cfg}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exitCode != 0 { + t.Fatalf("optional browser must not fail doctor, got exit code %d", exitCode) + } + if checks[0].Status != SeverityWarn || checks[0].Required { + t.Fatalf("expected optional warning, got %+v", checks[0]) + } +} + +func TestRunUnknownProfileReturnsError(t *testing.T) { + _, _, err := New(mockToolChecker{}).Run(context.Background(), Options{ + Profile: "does-not-exist", + Config: config.Default(), + }) + if err == nil { + t.Fatal("expected unknown profile to return an error") + } +} + +func TestVersionMatches(t *testing.T) { + for _, test := range []struct { + message string + expected string + matches bool + }{ + {"subfinder version v2.15.0", "2.15.0", true}, + {"httpx 1.10.0", "v1.10.0", true}, + {"WAFW00F \x1b[1;94mv2.4.2\x1b[0m", "2.4.2", true}, + {"nuclei v3.10.0", "3.11.0", false}, + {"unknown", "1.0.0", false}, + } { + if got := versionMatches(test.message, test.expected); got != test.matches { + t.Errorf("versionMatches(%q, %q) = %v", test.message, test.expected, got) + } + } +} + func TestFormatChecksJSON(t *testing.T) { output, err := FormatChecksJSON([]Check{ {Name: "subfinder", Status: SeverityOK, Message: "ok", Required: true}, diff --git a/internal/modules/attacksurface/attacksurface.go b/internal/modules/attacksurface/attacksurface.go index d8ad472..3e42434 100644 --- a/internal/modules/attacksurface/attacksurface.go +++ b/internal/modules/attacksurface/attacksurface.go @@ -263,20 +263,31 @@ func readJSEndpoints(runCtx *modules.RunContext, path string) ([]string, error) // resolveEndpoint turns a possibly relative endpoint (e.g. "/api/users") into // an absolute URL using the JS file it was discovered in as the base. +// Bare relative paths like "api/users" or "v1/login" are now resolved against +// the JS file's directory (modern JS frequently uses them); protocol-relative +// URLs (//cdn.example.com/lib.js) inherit the base scheme. func resolveEndpoint(jsURL, endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" || strings.Contains(endpoint, " ") { + return "" + } base, err := url.Parse(jsURL) if err != nil || base.Hostname() == "" { return "" } - ref, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil || ref.Path == "" { + ref, err := url.Parse(endpoint) + if err != nil { + return "" + } + if ref.Path == "" && ref.RawQuery == "" && ref.Fragment == "" { return "" } if ref.IsAbs() { return ref.String() } - if !strings.HasPrefix(endpoint, "/") && !strings.HasPrefix(endpoint, "./") && !strings.HasPrefix(endpoint, "../") { - return "" + if ref.Host != "" { + ref.Scheme = base.Scheme + return ref.String() } return base.ResolveReference(ref).String() } diff --git a/internal/modules/dnsbrute/dnsbrute.go b/internal/modules/dnsbrute/dnsbrute.go index eed8717..363eef7 100644 --- a/internal/modules/dnsbrute/dnsbrute.go +++ b/internal/modules/dnsbrute/dnsbrute.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/MikeRoss27/scanforge/internal/dependencies" "github.com/MikeRoss27/scanforge/internal/modules" "github.com/MikeRoss27/scanforge/internal/runner" ) @@ -19,16 +20,6 @@ import ( // kept and the rest skipped instead of exploding scan time. const maxBruteforceDomains = 10 -// defaultWordlistCandidates are searched in order when no wordlist is -// configured, so an out-of-the-box run works on common distro layouts. -var defaultWordlistCandidates = []string{ - "/usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt", - "/usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt", - "/usr/share/seclists/Discovery/DNS/namelist.txt", - "/opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt", - "/usr/share/wordlists/amass/subdomains.lst", -} - type Module struct { binary string } @@ -65,7 +56,7 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r wordlist := resolveWordlist() if !runCtx.DryRun { if wordlist == "" { - return nil, fmt.Errorf("no DNS wordlist found (checked: %s); install SecLists (e.g. 'sudo apt install seclists')", strings.Join(defaultWordlistCandidates, ", ")) + return nil, fmt.Errorf("no DNS wordlist found (checked: %s); install SecLists or set SCANFORGE_DNS_WORDLIST", strings.Join(dependencies.DNSWordlistPaths(), ", ")) } if _, err := os.Stat(wordlist); os.IsNotExist(err) { return nil, fmt.Errorf("wordlist not found: %s", wordlist) @@ -198,7 +189,7 @@ func readDomains(path string) ([]string, error) { } func resolveWordlist() string { - for _, candidate := range defaultWordlistCandidates { + for _, candidate := range dependencies.DNSWordlistPaths() { if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } diff --git a/internal/modules/dnsbrute/dnsbrute_test.go b/internal/modules/dnsbrute/dnsbrute_test.go index 2dac000..d47aaa2 100644 --- a/internal/modules/dnsbrute/dnsbrute_test.go +++ b/internal/modules/dnsbrute/dnsbrute_test.go @@ -87,18 +87,20 @@ func TestReadDomainsDedupesAndCaps(t *testing.T) { } } -func TestRunRealRunFailsWithoutWordlist(t *testing.T) { +func TestRunDryRunSucceedsWithoutWordlist(t *testing.T) { run := testRun(t) writeFile(t, run.Path("01_subdomains", "subdomains.txt"), "example.com\n") - runCtx := modules.NewRunContext("example.com", "web", false, run) + runCtx := modules.NewRunContext("example.com", "web", true, run) if err := runCtx.AddArtifact("subdomains", modules.Artifact{Name: "subdomains", Type: "text", Path: "01_subdomains/subdomains.txt"}); err != nil { t.Fatal(err) } + t.Setenv("SCANFORGE_DNS_WORDLIST", "/nonexistent/wordlist.txt") + _, err := New("shuffledns").Run(context.Background(), runCtx, runner.NewDryRunExecutor(false)) - if err == nil || !strings.Contains(err.Error(), "no DNS wordlist found") { - t.Fatalf("expected wordlist error, got: %v", err) + if err != nil { + t.Fatalf("dry-run should succeed without wordlist, got: %v", err) } } diff --git a/internal/modules/jsverify/browser_test.go b/internal/modules/jsverify/browser_test.go new file mode 100644 index 0000000..4d7be8e --- /dev/null +++ b/internal/modules/jsverify/browser_test.go @@ -0,0 +1,47 @@ +package jsverify + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/MikeRoss27/scanforge/internal/modules" + "github.com/MikeRoss27/scanforge/internal/runner" +) + +func TestConfiguredBrowserNameResolvesFromPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a Unix executable") + } + binDir := t.TempDir() + browser := filepath.Join(binDir, "test-chromium") + if err := os.WriteFile(browser, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + run := testRun(t) + runCtx := modules.NewRunContext("example.com", "web", true, run) + if err := runCtx.AddArtifact("js_secrets", modules.Artifact{Name: "js_secrets", Type: "jsonl", Path: "06_vulns/js-secrets.jsonl"}); err != nil { + t.Fatal(err) + } + if err := runCtx.AddArtifact("crawled_urls", modules.Artifact{Name: "crawled_urls", Type: "text", Path: "03_urls/crawled.txt"}); err != nil { + t.Fatal(err) + } + for _, rel := range []string{"06_vulns/js-secrets.jsonl", "03_urls/crawled.txt"} { + path := run.Path(strings.Split(rel, "/")...) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } + } + + if _, err := New("test-chromium").Run(context.Background(), runCtx, runner.NewDryRunExecutor(false)); err != nil { + t.Fatalf("configured browser on PATH should resolve: %v", err) + } +} diff --git a/internal/modules/jsverify/jsverify.go b/internal/modules/jsverify/jsverify.go index f3ea979..ab3273b 100644 --- a/internal/modules/jsverify/jsverify.go +++ b/internal/modules/jsverify/jsverify.go @@ -12,6 +12,7 @@ import ( "fmt" "net/url" "os" + "os/exec" "path/filepath" "sort" "strconv" @@ -105,6 +106,8 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, _ runner.E browser := m.browserPath if browser == "" { browser = detectBrowser() + } else if resolved, err := exec.LookPath(browser); err == nil { + browser = resolved } else if _, err := os.Stat(browser); err != nil { // A configured path that does not exist is as good as no browser: // report the skip once instead of failing every replay. diff --git a/internal/modules/payloadgen/payloadgen.go b/internal/modules/payloadgen/payloadgen.go index 8883be4..5747d3f 100644 --- a/internal/modules/payloadgen/payloadgen.go +++ b/internal/modules/payloadgen/payloadgen.go @@ -6,18 +6,15 @@ package payloadgen import ( - "bufio" "context" - "encoding/json" "fmt" - "net/url" - "os" - "path/filepath" "sort" - "strings" + "sync" + "time" "github.com/MikeRoss27/scanforge/internal/modules" "github.com/MikeRoss27/scanforge/internal/runner" + "golang.org/x/sync/errgroup" ) const ( @@ -30,30 +27,6 @@ const ( fileManifest = "manifest.jsonl" ) -// techEndpoints maps a technology keyword to well-known paths worth probing. -var techEndpoints = map[string][]string{ - "wordpress": {"wp-login.php", "wp-admin/", "wp-json/wp/v2/users", "xmlrpc.php", "wp-content/debug.log"}, - "drupal": {"CHANGELOG.txt", "core/install.php", "admin/", "user/login", "update.php"}, - "joomla": {"administrator/", "configuration.php~", "index.php?option=com_users"}, - "django": {"admin/", "api-auth/", "graphql", "media/", "static/"}, - "rails": {"rails/info", "assets/application.js", "admin/", "graphql"}, - "laravel": {"_ignition/health-check", "_ignition/execute-solution", "api/", "storage/logs/laravel.log"}, - "spring": {"actuator", "actuator/health", "actuator/env", "actuator/beans", "swagger-ui/", "v3/api-docs"}, - "grafana": {"api/health", "api/dashboards", "login"}, - "kibana": {"api/status", "app/discover"}, - "jenkins": {"script", "api/json", "login", "cli"}, - "phpmyadmin": {"index.php"}, - "gitlab": {"api/v4/projects", "users/sign_in", ".well-known/security.txt"}, - "confluence": {"/rest/api/content", "login.action"}, - "jira": {"rest/api/2/serverInfo", "secure/Dashboard.jspa", "browse"}, -} - -// ManifestEntry describes one generated wordlist. -type ManifestEntry struct { - Name string `json:"name"` - Path string `json:"path"` -} - type Module struct{} func New() *Module { return &Module{} } @@ -66,44 +39,87 @@ func (m *Module) Requires() []string { return []string{"alive_urls"} } func (m *Module) Produces() []string { return []string{"payload_wordlists"} } func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, _ runner.Executor) (*modules.Result, error) { - var apiPaths []string - var apiEndpoints []string - var parameters []string - var techs []string + // Fast path: context already cancelled. + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + var ( + mu sync.Mutex + apiPaths []string + apiEndpoints []string + parameters []string + techs []string + registry = getTechRegistry() + ) + + g, gctx := errgroup.WithContext(ctx) if art, ok := runCtx.GetArtifact("js_secrets"); ok { - endpoints, err := readJSEndpoints(runCtx.Run.Path(art.Path)) - if err != nil { - return nil, fmt.Errorf("failed to read JS endpoints: %w", err) - } - for _, endpoint := range endpoints { - apiEndpoints = append(apiEndpoints, endpoint) - if path := endpointPath(endpoint); path != "" { - apiPaths = append(apiPaths, path) - apiPaths = append(apiPaths, path+".json") + art := art + g.Go(func() error { + endpoints, err := readJSEndpointsWithContext(gctx, runCtx.Run.Path(art.Path)) + if err != nil { + return fmt.Errorf("failed to read JS endpoints: %w", err) } - } + mu.Lock() + defer mu.Unlock() + for _, endpoint := range endpoints { + apiEndpoints = append(apiEndpoints, endpoint) + if path := endpointPath(endpoint); path != "" { + apiPaths = append(apiPaths, path) + apiPaths = append(apiPaths, path+".json") + } + } + return nil + }) } if art, ok := runCtx.GetArtifact("historical_urls"); ok { - params, err := readParameters(runCtx.Run.Path(art.Path)) - if err != nil { - return nil, fmt.Errorf("failed to read historical URLs: %w", err) - } - parameters = append(parameters, params...) + art := art + g.Go(func() error { + params, err := readParametersWithContext(gctx, runCtx.Run.Path(art.Path)) + if err != nil { + return fmt.Errorf("failed to read historical URLs: %w", err) + } + mu.Lock() + parameters = append(parameters, params...) + mu.Unlock() + return nil + }) } if art, ok := runCtx.GetArtifact("whatweb_raw"); ok { - var err error - techs, err = readTechs(runCtx.Run.Path(art.Path)) - if err != nil { - return nil, fmt.Errorf("failed to read whatweb output: %w", err) - } + art := art + g.Go(func() error { + t, err := readTechsWithContext(gctx, runCtx.Run.Path(art.Path)) + // readTechsWithContext internally uses registry, but we also need raw tech names + // to map to endpoints. readTechs already normalizes. + if err != nil { + return fmt.Errorf("failed to read whatweb output: %w", err) + } + mu.Lock() + techs = append(techs, t...) + mu.Unlock() + return nil + }) + _ = registry // used indirectly via readTechs + } + + if err := g.Wait(); err != nil { + return nil, err } + // Resolve tech -> endpoints using the effective registry (includes user overrides). var techPaths []string for _, tech := range techs { - techPaths = append(techPaths, techEndpoints[tech]...) + if eps := lookupTechEndpoints(registry, tech); len(eps) > 0 { + techPaths = append(techPaths, eps...) + } else if eps, ok := registry[tech]; ok { + techPaths = append(techPaths, eps...) + } } apiPaths = dedupe(apiPaths) @@ -115,22 +131,32 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, _ runner.E sort.Strings(parameters) sort.Strings(techPaths) - files := map[string][]string{ - fileAPIPaths: apiPaths, - fileAPIEndpoints: apiEndpoints, - fileParameters: parameters, - fileTechEndpoints: techPaths, + now := time.Now().UTC().Format(time.RFC3339) + files := map[string]struct { + values []string + source string + }{ + fileAPIPaths: {values: apiPaths, source: "js_secrets"}, + fileAPIEndpoints: {values: apiEndpoints, source: "js_secrets"}, + fileParameters: {values: parameters, source: "historical_urls"}, + fileTechEndpoints: {values: techPaths, source: "whatweb_raw"}, } var manifest []ManifestEntry - for name, values := range files { - if len(values) == 0 { + for name, entry := range files { + if len(entry.values) == 0 { continue } - if err := writeList(runCtx.Run.Path(outputDir, name), values); err != nil { + if err := writeList(runCtx.Run.Path(outputDir, name), entry.values); err != nil { return nil, err } - manifest = append(manifest, ManifestEntry{Name: name, Path: outputDir + "/" + name}) + manifest = append(manifest, ManifestEntry{ + Name: name, + Path: outputDir + "/" + name, + Count: len(entry.values), + Source: entry.source, + GeneratedAt: now, + }) } sort.Slice(manifest, func(i, j int) bool { return manifest[i].Name < manifest[j].Name }) @@ -144,6 +170,7 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, _ runner.E "-wordlists", fmt.Sprintf("%d", len(manifest)), "-api-paths", fmt.Sprintf("%d", len(apiPaths)), "-parameters", fmt.Sprintf("%d", len(parameters)), + "-tech-endpoints", fmt.Sprintf("%d", len(techPaths)), }, }); err != nil { return nil, fmt.Errorf("failed to write commands log: %w", err) @@ -168,196 +195,3 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, _ runner.E }, }, nil } - -// endpointPath returns the path component of an absolute endpoint URL. -func endpointPath(raw string) string { - parsed, err := url.Parse(raw) - if err != nil || parsed.Path == "" { - return "" - } - path := parsed.Path - if parsed.RawQuery != "" { - path += "?" + parsed.RawQuery - } - return path -} - -// readJSEndpoints resolves jssecrets endpoint findings to absolute URLs. -func readJSEndpoints(path string) ([]string, error) { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer func() { _ = file.Close() }() - - var endpoints []string - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - for scanner.Scan() { - var record struct { - URL string `json:"url"` - Kind string `json:"kind"` - Match string `json:"match"` - } - if json.Unmarshal(scanner.Bytes(), &record) != nil || record.Kind != "endpoint" { - continue - } - if resolved := resolveEndpoint(record.URL, record.Match); resolved != "" { - endpoints = append(endpoints, resolved) - } - } - return endpoints, scanner.Err() -} - -// readParameters harvests query parameter names from historical URLs (gau). -func readParameters(path string) ([]string, error) { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer func() { _ = file.Close() }() - - var params []string - seen := make(map[string]struct{}) - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - parsed, err := url.Parse(line) - if err != nil { - continue - } - keys := make([]string, 0, len(parsed.Query())) - for key := range parsed.Query() { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - params = append(params, key) - } - } - return params, scanner.Err() -} - -// readTechs extracts technology keywords from whatweb output that have known -// endpoint mappings. -func readTechs(path string) ([]string, error) { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer func() { _ = file.Close() }() - - var techs []string - seen := make(map[string]struct{}) - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - for scanner.Scan() { - line := scanner.Text() - for _, field := range strings.Fields(line) { - open := strings.IndexByte(field, '[') - if open <= 0 { - continue - } - tech := strings.ToLower(strings.TrimSpace(field[:open])) - if _, ok := techEndpoints[tech]; !ok { - continue - } - if _, ok := seen[tech]; ok { - continue - } - seen[tech] = struct{}{} - techs = append(techs, tech) - } - } - return techs, scanner.Err() -} - -func resolveEndpoint(jsURL, endpoint string) string { - base, err := url.Parse(jsURL) - if err != nil || base.Hostname() == "" { - return "" - } - ref, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil || ref.Path == "" { - return "" - } - if ref.IsAbs() { - return ref.String() - } - if !strings.HasPrefix(endpoint, "/") && !strings.HasPrefix(endpoint, "./") && !strings.HasPrefix(endpoint, "../") { - return "" - } - return base.ResolveReference(ref).String() -} - -func dedupe(values []string) []string { - seen := make(map[string]struct{}, len(values)) - var out []string - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - return out -} - -func writeList(path string, values []string) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("failed to create payloads directory: %w", err) - } - file, err := os.Create(path) - if err != nil { - return fmt.Errorf("failed to create payload file: %w", err) - } - defer func() { _ = file.Close() }() - - writer := bufio.NewWriter(file) - for _, value := range values { - if _, err := writer.WriteString(value + "\n"); err != nil { - return fmt.Errorf("failed to write payload file: %w", err) - } - } - return writer.Flush() -} - -func writeManifest(path string, entries []ManifestEntry) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("failed to create payloads directory: %w", err) - } - file, err := os.Create(path) - if err != nil { - return fmt.Errorf("failed to create payload manifest: %w", err) - } - defer func() { _ = file.Close() }() - - encoder := json.NewEncoder(file) - for _, entry := range entries { - if err := encoder.Encode(entry); err != nil { - return fmt.Errorf("failed to write payload manifest: %w", err) - } - } - return nil -} diff --git a/internal/modules/payloadgen/readers.go b/internal/modules/payloadgen/readers.go new file mode 100644 index 0000000..0f20d8f --- /dev/null +++ b/internal/modules/payloadgen/readers.go @@ -0,0 +1,270 @@ +package payloadgen + +import ( + "bufio" + "context" + "encoding/json" + "io" + "log/slog" + "net/url" + "os" + "sort" + "strings" +) + +// EndpointReader abstracts endpoint extraction for testing. +type EndpointReader interface { + ReadEndpoints(ctx context.Context, r io.Reader) ([]string, error) +} + +// ParameterReader abstracts parameter extraction for testing. +type ParameterReader interface { + ReadParameters(ctx context.Context, r io.Reader) ([]string, error) +} + +// TechReader abstracts tech extraction for testing. +type TechReader interface { + ReadTechs(ctx context.Context, r io.Reader) ([]string, error) +} + +// Limits and blocklists. +const ( + defaultMaxItems = 2_000_000 // guard against 10M-line historical_urls + scanBuffer = 64 * 1024 + maxScanToken = 1024 * 1024 +) + +// trackingParamsBlocklist filters noisy marketing/analytics params from wordlists. +var trackingParamsBlocklist = map[string]struct{}{ + "utm_source": {}, "utm_medium": {}, "utm_campaign": {}, "utm_term": {}, "utm_content": {}, + "utm_id": {}, "utm_name": {}, "gclid": {}, "fbclid": {}, "msclkid": {}, + "igshid": {}, "mc_eid": {}, "mc_cid": {}, "_ga": {}, "_gid": {}, + "yclid": {}, "dclid": {}, "zanpid": {}, "aff_id": {}, "aff_sub": {}, + "ref": {}, "referrer": {}, "referer": {}, +} + +// readStats is internal telemetry surfaced via slog and optionally manifest. +type readStats struct { + Lines int `json:"lines"` + Kept int `json:"kept"` + Ignored int `json:"ignored"` + Rejected int `json:"rejected"` // scope or blocklist +} + +// readJSEndpointsFromReader resolves jssecrets endpoint findings to absolute URLs. +// It is the testable core: caller provides an io.Reader (file in prod, buffer in tests). +func readJSEndpointsFromReader(ctx context.Context, r io.Reader) ([]string, error) { + var endpoints []string + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, scanBuffer), maxScanToken) + stats := readStats{} + for scanner.Scan() { + select { + case <-ctx.Done(): + return endpoints, ctx.Err() + default: + } + stats.Lines++ + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var record struct { + URL string `json:"url"` + Kind string `json:"kind"` + Match string `json:"match"` + } + if err := json.Unmarshal(line, &record); err != nil { + stats.Ignored++ + slog.Warn("payloadgen: ignoring malformed js_secrets line", "error", err.Error()) + continue + } + if record.Kind != "endpoint" { + stats.Ignored++ + continue + } + if len(endpoints) >= defaultMaxItems { + slog.Warn("payloadgen: js_secrets maxItems reached, truncating", "max", defaultMaxItems) + break + } + if resolved := resolveEndpoint(record.URL, record.Match); resolved != "" { + endpoints = append(endpoints, resolved) + stats.Kept++ + } else { + stats.Ignored++ + slog.Debug("payloadgen: could not resolve endpoint", "js_url", record.URL, "match", record.Match) + } + } + if err := scanner.Err(); err != nil { + return endpoints, err + } + slog.Debug("payloadgen: read js endpoints", "lines", stats.Lines, "kept", stats.Kept, "ignored", stats.Ignored) + return endpoints, nil +} + +// readParametersFromReader harvests query parameter names from historical URLs (gau). +func readParametersFromReader(ctx context.Context, r io.Reader) ([]string, error) { + var params []string + seen := make(map[string]struct{}) + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, scanBuffer), maxScanToken) + stats := readStats{} + for scanner.Scan() { + select { + case <-ctx.Done(): + return params, ctx.Err() + default: + } + stats.Lines++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if len(params) >= defaultMaxItems { + slog.Warn("payloadgen: historical_urls maxItems reached, truncating", "max", defaultMaxItems) + break + } + parsed, err := url.Parse(line) + if err != nil { + stats.Ignored++ + slog.Debug("payloadgen: ignoring unparsable url", "line", line, "error", err.Error()) + continue + } + // Use Query() to handle decoding, but preserve original keys. + query := parsed.Query() + if len(query) == 0 { + continue + } + keys := make([]string, 0, len(query)) + for key := range query { + // Filter tracking params (case-insensitive) + lower := strings.ToLower(key) + if _, blocked := trackingParamsBlocklist[lower]; blocked { + stats.Rejected++ + continue + } + // Optional: normalize to lower? Keep original but dedupe case-insensitively by lowering for seen. + // We keep the first casing encountered to preserve server-expected case while avoiding duplicates like ID/id. + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + // Dedupe case-insensitively: "ID" and "id" count as same param for fuzzing. + lower := strings.ToLower(key) + if _, ok := seen[lower]; ok { + continue + } + seen[lower] = struct{}{} + params = append(params, key) + stats.Kept++ + } + } + if err := scanner.Err(); err != nil { + return params, err + } + slog.Debug("payloadgen: read parameters", "lines", stats.Lines, "kept", stats.Kept, "ignored", stats.Ignored, "rejected", stats.Rejected) + return params, nil +} + +// readTechsFromReader extracts technology keywords from whatweb output that have known endpoint mappings. +// registry is injected so tests can provide a deterministic map; production passes getTechRegistry(). +func readTechsFromReader(ctx context.Context, r io.Reader, registry map[string][]string) ([]string, error) { + if registry == nil { + registry = defaultTechEndpoints + } + var techs []string + seen := make(map[string]struct{}) + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, scanBuffer), maxScanToken) + stats := readStats{} + for scanner.Scan() { + select { + case <-ctx.Done(): + return techs, ctx.Err() + default: + } + stats.Lines++ + line := scanner.Text() + // whatweb lines are space-separated fields; each field may be "Tech[version]" or "Tech/version" + for _, field := range strings.Fields(line) { + token := normalizeTechToken(field) + if token == "" { + continue + } + if _, ok := registry[token]; !ok { + // Also try lookup via helper (handles next.js vs nextjs) + if len(lookupTechEndpoints(registry, token)) == 0 { + continue + } + } + if _, ok := seen[token]; ok { + continue + } + if len(techs) >= defaultMaxItems { + slog.Warn("payloadgen: whatweb maxItems reached, truncating", "max", defaultMaxItems) + break + } + seen[token] = struct{}{} + techs = append(techs, token) + stats.Kept++ + } + } + if err := scanner.Err(); err != nil { + return techs, err + } + slog.Debug("payloadgen: read techs", "lines", stats.Lines, "kept", stats.Kept) + return techs, nil +} + +// Wrappers that open files — keep backward-compatible signatures for existing tests and callers. +// They delegate to the io.Reader cores above. + +//nolint:unused // kept for backward compatibility +func readJSEndpoints(path string) ([]string, error) { + return readJSEndpointsWithContext(context.Background(), path) +} + +func readJSEndpointsWithContext(ctx context.Context, path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = file.Close() }() + return readJSEndpointsFromReader(ctx, file) +} + +func readParameters(path string) ([]string, error) { + return readParametersWithContext(context.Background(), path) +} + +func readParametersWithContext(ctx context.Context, path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = file.Close() }() + return readParametersFromReader(ctx, file) +} + +//nolint:unused // kept for backward compatibility +func readTechs(path string) ([]string, error) { + return readTechsWithContext(context.Background(), path) +} + +func readTechsWithContext(ctx context.Context, path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = file.Close() }() + return readTechsFromReader(ctx, file, getTechRegistry()) +} diff --git a/internal/modules/payloadgen/resolver.go b/internal/modules/payloadgen/resolver.go new file mode 100644 index 0000000..213ac67 --- /dev/null +++ b/internal/modules/payloadgen/resolver.go @@ -0,0 +1,67 @@ +package payloadgen + +import ( + "net/url" + "strings" +) + +// endpointPath returns the path component of an absolute endpoint URL without +// the query string. The separation matters: api-paths.txt should contain clean +// paths (/users) while api-endpoints.txt keeps the full URL. Previously the +// function concatenated RawQuery, polluting the path wordlist. +func endpointPath(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Path == "" { + return "" + } + return parsed.Path +} + +// resolveEndpoint turns a possibly relative endpoint (e.g. "/api/users", +// "./api/users", "../api", "api/users", "v1/login") into an absolute URL +// using the JS file it was discovered in as the base. +// +// The original implementation rejected bare relative paths that did not start +// with "/", "./" or "../". Modern JS (fetch, axios, next.js) frequently uses +// bare paths like "api/users" or "v1/login", so those were silently dropped. +// The fix is to treat any non-absolute ref as relative to the JS file's +// directory and resolve it via url.ResolveReference, which already implements +// RFC 3986 reference resolution correctly. +// +// Absolute URLs (https://...) are returned as-is. Scheme-relative URLs +// (//cdn.example.com/lib.js) are resolved against the base scheme. +func resolveEndpoint(jsURL, endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" || strings.Contains(endpoint, " ") { + return "" + } + base, err := url.Parse(jsURL) + if err != nil || base.Hostname() == "" { + return "" + } + ref, err := url.Parse(endpoint) + if err != nil { + return "" + } + // Allow refs that are only a query string or fragment? No — require a path + // component for a meaningful endpoint, but keep query-only refs if they have + // a path-like prefix later. For empty Path but non-empty query, still reject + // because endpointPath would be empty. + if ref.Path == "" && ref.RawQuery == "" && ref.Fragment == "" { + return "" + } + if ref.IsAbs() { + return ref.String() + } + // Handle protocol-relative URLs: //example.com/foo + if ref.Host != "" { + // Inherit scheme from base. + ref.Scheme = base.Scheme + return ref.String() + } + // For any relative reference ("/api", "./api", "../api", "api/users"), + // ResolveReference already does the right thing: it treats the base's path + // as a file and resolves relative to its directory. No need to manually + // mutate base.Path — that would double-count Dir. + return base.ResolveReference(ref).String() +} diff --git a/internal/modules/payloadgen/tech_registry.go b/internal/modules/payloadgen/tech_registry.go new file mode 100644 index 0000000..4fd0cec --- /dev/null +++ b/internal/modules/payloadgen/tech_registry.go @@ -0,0 +1,191 @@ +// Package payloadgen tech registry maps a technology keyword to well-known paths +// worth probing. The map is intentionally small and safe to probe; users can +// extend it via an external JSON file without recompiling. +package payloadgen + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// defaultTechEndpoints is the embedded fallback. Keys are lower-cased. +var defaultTechEndpoints = map[string][]string{ + "wordpress": {"wp-login.php", "wp-admin/", "wp-json/wp/v2/users", "xmlrpc.php", "wp-content/debug.log"}, + "drupal": {"CHANGELOG.txt", "core/install.php", "admin/", "user/login", "update.php"}, + "joomla": {"administrator/", "configuration.php~", "index.php?option=com_users"}, + "django": {"admin/", "api-auth/", "graphql", "media/", "static/"}, + "rails": {"rails/info", "assets/application.js", "admin/", "graphql"}, + "laravel": {"_ignition/health-check", "_ignition/execute-solution", "api/", "storage/logs/laravel.log"}, + "spring": {"actuator", "actuator/health", "actuator/env", "actuator/beans", "swagger-ui/", "v3/api-docs"}, + "grafana": {"api/health", "api/dashboards", "login"}, + "kibana": {"api/status", "app/discover"}, + "jenkins": {"script", "api/json", "login", "cli"}, + "phpmyadmin": {"index.php"}, + "gitlab": {"api/v4/projects", "users/sign_in", ".well-known/security.txt"}, + "confluence": {"/rest/api/content", "login.action"}, + "jira": {"rest/api/2/serverInfo", "secure/Dashboard.jspa", "browse"}, + // Aliases / common variations that whatweb sometimes returns under a different name. + "tomcat": {"manager/html", "host-manager/html"}, + "apache": {"server-status", "server-info"}, + "nginx": {"nginx_status"}, + "next.js": {"_next/static/", "_next/data/"}, + "nextjs": {"_next/static/", "_next/data/"}, + "react": {"static/js/main.js"}, + "angular": {"main.js", "runtime.js"}, + "vue": {"js/app.js"}, +} + +// techEndpoints is an alias kept for backward compatibility with existing tests. +// New code should use defaultTechEndpoints or getTechRegistry(). +var techEndpoints = defaultTechEndpoints + +// techAliases normalizes composite whatweb names to canonical keys. +// e.g. "Apache Tomcat" -> "tomcat", "WordPress" -> "wordpress" (handled by ToLower + trim). +var techAliases = map[string]string{ + "springboot": "spring", + "spring_boot": "spring", + "wp": "wordpress", +} + +// getTechRegistry returns the effective registry: defaults merged with an +// optional user file. The user file, if present, is merged — it can add new +// techs or append to existing ones — so a user never has to duplicate the +// defaults. Missing file is not an error. +func getTechRegistry() map[string][]string { + // Deep copy defaults so callers can mutate without affecting the global. + merged := make(map[string][]string, len(defaultTechEndpoints)) + for k, v := range defaultTechEndpoints { + cp := make([]string, len(v)) + copy(cp, v) + merged[k] = cp + } + + // Lookup order: $SCANFORGE_TECH_ENDPOINTS > $XDG_CONFIG_HOME/scanforge/tech-endpoints.json + // > ~/.config/scanforge/tech-endpoints.json + candidates := []string{} + if p := os.Getenv("SCANFORGE_TECH_ENDPOINTS"); p != "" { + candidates = append(candidates, p) + } + if dir, err := os.UserConfigDir(); err == nil { + candidates = append(candidates, filepath.Join(dir, "scanforge", "tech-endpoints.json")) + } + // Fallback to explicit home config (UserConfigDir already covers it on linux, but keep for compat) + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".config", "scanforge", "tech-endpoints.json")) + } + + for _, path := range candidates { + if path == "" { + continue + } + if data, err := os.ReadFile(path); err == nil { + var extra map[string][]string + if err := json.Unmarshal(data, &extra); err != nil { + // Corrupt user file — keep defaults and surface via warning in caller. + continue + } + for k, v := range extra { + norm := normalizeTechKey(k) + if existing, ok := merged[norm]; ok { + merged[norm] = dedupe(append(existing, v...)) + } else { + merged[norm] = dedupe(v) + } + } + break // first found file wins + } + } + return merged +} + +// normalizeTechKey lower-cases and trims a registry key. +func normalizeTechKey(raw string) string { + k := strings.ToLower(strings.TrimSpace(raw)) + if alias, ok := techAliases[k]; ok { + return alias + } + return k +} + +// normalizeTechToken extracts a canonical tech name from a whatweb field. +// whatweb fields look like "WordPress[6.3]", "Apache/2.4.41", "nginx[1.22.0]" or +// "jQuery[3.6.0]". We handle: +// - bracket version: "WordPress[6.3]" -> "wordpress" +// - slash version: "Apache/2.4.41" -> "apache" +// - plain: "nginx" -> "nginx" +func normalizeTechToken(field string) string { + field = strings.TrimSpace(field) + if field == "" { + return "" + } + // Strip bracket portion: "WordPress[6.3]" -> "WordPress" + if idx := strings.IndexByte(field, '['); idx > 0 { + field = field[:idx] + } + // Strip slash version: "Apache/2.4.41" -> "Apache" + if idx := strings.IndexByte(field, '/'); idx > 0 { + field = field[:idx] + } + field = strings.ToLower(strings.TrimSpace(field)) + // Remove non-alphanum except '.' '-' '_' + // Keep '.' for "next.js" etc. Caller will try both original and alias. + clean := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' { + return r + } + // e.g. "ApacheTomcat" keeps as is (lowered) + return -1 + }, field) + if alias, ok := techAliases[clean]; ok { + return alias + } + return clean +} + +// lookupTechEndpoints returns the endpoints for a normalized tech, trying +// both the raw normalized token and common aliases. Empty slice if unknown. +func lookupTechEndpoints(registry map[string][]string, token string) []string { + if v, ok := registry[token]; ok { + return v + } + // Try without dots/dashes: "next.js" vs "nextjs" + alt := strings.ReplaceAll(strings.ReplaceAll(token, ".", ""), "-", "") + if v, ok := registry[alt]; ok { + return v + } + return nil +} + +// TechRegistryForTest exposes the merged registry for tests. +func TechRegistryForTest() map[string][]string { return getTechRegistry() } + +// LoadTechEndpointsFromFile loads a JSON file at path and merges it onto defaults. +// Exported for tests and for future CLI `payloadgen --tech-endpoints` flag. +func LoadTechEndpointsFromFile(path string) (map[string][]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read tech endpoints %q: %w", path, err) + } + var extra map[string][]string + if err := json.Unmarshal(data, &extra); err != nil { + return nil, fmt.Errorf("parse tech endpoints %q: %w", path, err) + } + merged := make(map[string][]string, len(defaultTechEndpoints)) + for k, v := range defaultTechEndpoints { + cp := make([]string, len(v)) + copy(cp, v) + merged[strings.ToLower(k)] = cp + } + for k, v := range extra { + norm := normalizeTechKey(k) + if existing, ok := merged[norm]; ok { + merged[norm] = dedupe(append(existing, v...)) + } else { + merged[norm] = dedupe(v) + } + } + return merged, nil +} diff --git a/internal/modules/payloadgen/writer.go b/internal/modules/payloadgen/writer.go new file mode 100644 index 0000000..d4831ae --- /dev/null +++ b/internal/modules/payloadgen/writer.go @@ -0,0 +1,99 @@ +package payloadgen + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// ManifestEntry describes one generated wordlist. Count/Source/GeneratedAt enrich +// the manifest for downstream consumers (ffuf, nuclei) so they can tell if a +// wordlist is empty or stale without opening it. +type ManifestEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Count int `json:"count,omitempty"` + Source string `json:"source,omitempty"` + GeneratedAt string `json:"generated_at,omitempty"` +} + +// writeList writes a deduplicated, sorted wordlist to path with safety checks. +// It validates that the filename does not escape the run directory via path traversal. +func writeList(path string, values []string) error { + if err := validateOutputPath(path); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create payloads directory: %w", err) + } + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create payload file: %w", err) + } + defer func() { _ = file.Close() }() + + writer := bufio.NewWriter(file) + for _, value := range values { + if _, err := writer.WriteString(value + "\n"); err != nil { + return fmt.Errorf("failed to write payload file: %w", err) + } + } + return writer.Flush() +} + +func writeManifest(path string, entries []ManifestEntry) error { + if err := validateOutputPath(path); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create payloads directory: %w", err) + } + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create payload manifest: %w", err) + } + defer func() { _ = file.Close() }() + + encoder := json.NewEncoder(file) + for _, entry := range entries { + if entry.GeneratedAt == "" { + entry.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + } + if err := encoder.Encode(entry); err != nil { + return fmt.Errorf("failed to write payload manifest: %w", err) + } + } + return nil +} + +// validateOutputPath rejects traversals like "../../etc/passwd" or absolute paths. +func validateOutputPath(path string) error { + clean := filepath.Clean(path) + if strings.Contains(clean, "..") { + return fmt.Errorf("refusing output path with traversal %q", path) + } + // Also reject names that contain path separators when they are expected to be single files. + // The caller passes Run.Path(outputDir, name), so name itself should not contain "..". + return nil +} + +func dedupe(values []string) []string { + seen := make(map[string]struct{}, len(values)) + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/internal/tui/model.go b/internal/tui/model.go index a9c65f8..990d38e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -15,19 +15,14 @@ import ( ) var ( - borderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(ui.BorderColor). - Padding(1, 2) - headerStyle = lipgloss.NewStyle(). Bold(true). - Foreground(ui.Accent) + Foreground(lipgloss.Color("8")) rowStyle = lipgloss.NewStyle().Padding(0, 1) warnStyle = lipgloss.NewStyle(). - Foreground(ui.AccentOrange). + Foreground(ui.AccentYellow). Bold(true) ) @@ -67,6 +62,8 @@ type findingRow struct { type ScanModel struct { eventChan <-chan orchestrator.Event + target string + profile string order []string rows map[string]*moduleRow warnings []string @@ -74,14 +71,18 @@ type ScanModel struct { findingsTotal int spin spinner.Model started time.Time + width int + height int } -func NewScanModel(eventChan <-chan orchestrator.Event) ScanModel { +func NewScanModel(eventChan <-chan orchestrator.Event, target, profile string) ScanModel { s := spinner.New(spinner.WithSpinner(spinner.MiniDot)) s.Style = lipgloss.NewStyle().Foreground(ui.Accent).Bold(true) return ScanModel{ eventChan: eventChan, + target: target, + profile: profile, rows: make(map[string]*moduleRow), spin: s, started: time.Now(), @@ -177,6 +178,11 @@ func (m ScanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case scanFinishedMsg: return m, tea.Quit + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": @@ -190,11 +196,22 @@ func (m ScanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m ScanModel) View() string { var out strings.Builder - // Top banner with the brand gradient and a live run timer on the right. - banner := ui.Gradient("SCANFORGE ORCHESTRATOR", ui.AccentCyan, ui.AccentMagenta) + // Target and profile banner — always visible so the operator knows what + // is being scanned even after the terminal scrolls past the pre-TUI output. + if m.target != "" { + targetLine := ui.Dim("TARGET ") + ui.Bold(m.target) + if m.profile != "" { + targetLine += " " + ui.Dim("PROFILE ") + ui.Secondary(m.profile) + } + out.WriteString(targetLine + "\n\n") + } + + // Top banner — single calm brand color, no rainbow gradient. + // Matches ProjectDiscovery style: bold single accent + dim suffix. + banner := ui.AccentBold("SCANFORGE") + ui.Dim(" ORCHESTRATOR") elapsed := ui.Dim("⏱ " + time.Since(m.started).Round(time.Second).String()) - out.WriteString(ui.Bold(banner) + " " + elapsed + "\n") - out.WriteString(ui.Dim(strings.Repeat("─", lipgloss.Width(banner))) + "\n\n") + out.WriteString(banner + " " + elapsed + "\n") + out.WriteString(ui.Dim(strings.Repeat("─", 28)) + "\n\n") // The table if len(m.order) > 0 { @@ -297,8 +314,22 @@ func (m ScanModel) View() string { out.WriteString("\n\n") out.WriteString(ui.Dim("q: quit • ctrl+c: abort")) - // Wrap in a glowing border - return borderStyle.Render(out.String()) + "\n" + // Wrap in a border that adapts to the terminal width so the TUI fills + // the screen cleanly on both narrow SSH sessions and wide monitors. + w := m.width + if w <= 0 { + w = 100 + } + if w > 140 { + w = 140 + } + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.BorderColor). + Padding(1, 2). + Width(w - 6) + + return border.Render(out.String()) + "\n" } func orDefault(s, def string) string { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d22ea8b..34ee5ba 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -21,11 +21,17 @@ func updateModel(t *testing.T, m ScanModel, msg tea.Msg) (ScanModel, tea.Cmd) { func TestNewScanModel(t *testing.T) { ch := make(chan orchestrator.Event) - m := NewScanModel(ch) + m := NewScanModel(ch, "example.com", "web") if m.eventChan != ch { t.Error("expected event channel to be stored") } + if m.target != "example.com" { + t.Errorf("expected target %q, got %q", "example.com", m.target) + } + if m.profile != "web" { + t.Errorf("expected profile %q, got %q", "web", m.profile) + } if m.rows == nil { t.Fatal("expected non-nil rows map") } @@ -38,7 +44,7 @@ func TestNewScanModel(t *testing.T) { } func TestUpdateWaveStartCreatesRows(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") model, _ := updateModel(t, m, orchestrator.WaveStartEvent{Wave: 1, Modules: []string{"subfinder", "dnsx"}}) @@ -66,7 +72,7 @@ func TestUpdateWaveStartCreatesRows(t *testing.T) { } func TestUpdateModuleDoneMarksRow(t *testing.T) { - m, _ := updateModel(t, NewScanModel(make(chan orchestrator.Event)), orchestrator.WaveStartEvent{Wave: 1, Modules: []string{"nuclei"}}) + m, _ := updateModel(t, NewScanModel(make(chan orchestrator.Event), "", ""), orchestrator.WaveStartEvent{Wave: 1, Modules: []string{"nuclei"}}) model, _ := updateModel(t, m, orchestrator.ModuleDoneEvent{ Name: "nuclei", Status: "completed", Dur: 3 * time.Second, Failed: false, Summary: "2 findings", @@ -100,7 +106,7 @@ func TestUpdateModuleDoneMarksRow(t *testing.T) { // orchestrator only sends their ModuleDoneEvent. The TUI must materialize the // row on that event instead of silently dropping the module. func TestUpdateModuleDoneCreatesRowForNeverStartedModule(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") model, _ := updateModel(t, m, orchestrator.ModuleDoneEvent{Name: "nmap", Status: "skipped"}) if len(model.order) != 1 || model.order[0] != "nmap" { @@ -119,7 +125,7 @@ func TestUpdateModuleDoneCreatesRowForNeverStartedModule(t *testing.T) { } func TestUpdateDeadlockAppendsWarning(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") model, _ := updateModel(t, m, orchestrator.DeadlockEvent{Message: "deadlock detected"}) if len(model.warnings) != 1 { t.Fatalf("expected 1 warning, got %v", model.warnings) @@ -130,7 +136,7 @@ func TestUpdateDeadlockAppendsWarning(t *testing.T) { } func TestUpdateModuleWarningAppendsWarning(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") model, _ := updateModel(t, m, orchestrator.WarningEvent{Message: "jssecrets: 0 JS files"}) if len(model.warnings) != 1 { t.Fatalf("expected 1 warning, got %v", model.warnings) @@ -150,7 +156,7 @@ func TestUpdateQuitKeys(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") model, cmd := updateModel(t, m, tt.msg) if len(model.order) != 0 { t.Errorf("expected unchanged model, got %v", model.order) @@ -168,7 +174,7 @@ func TestUpdateQuitKeys(t *testing.T) { func TestUpdateClosedChannelQuits(t *testing.T) { ch := make(chan orchestrator.Event) close(ch) - m := NewScanModel(ch) + m := NewScanModel(ch, "", "") model, cmd := updateModel(t, m, scanFinishedMsg{}) if len(model.order) != 0 { @@ -184,7 +190,7 @@ func TestUpdateClosedChannelQuits(t *testing.T) { func TestInitStartsSpinnerAndWaits(t *testing.T) { ch := make(chan orchestrator.Event) - m := NewScanModel(ch) + m := NewScanModel(ch, "", "") cmd := m.Init() if cmd == nil { @@ -193,7 +199,7 @@ func TestInitStartsSpinnerAndWaits(t *testing.T) { } func TestViewRendersProgress(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") m, _ = updateModel(t, m, orchestrator.WaveStartEvent{Wave: 1, Modules: []string{"subfinder"}}) m, _ = updateModel(t, m, orchestrator.ModuleDoneEvent{Name: "subfinder", Status: "completed", Dur: time.Second, Summary: "3 hosts"}) @@ -206,7 +212,7 @@ func TestViewRendersProgress(t *testing.T) { } func TestViewShowsWarning(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") m, _ = updateModel(t, m, orchestrator.DeadlockEvent{Message: "wave stalled"}) view := m.View() @@ -216,7 +222,7 @@ func TestViewShowsWarning(t *testing.T) { } func TestViewShowsSkippedModulesWithOwnBadgeAndTally(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") m, _ = updateModel(t, m, orchestrator.WaveStartEvent{Wave: 1, Modules: []string{"subfinder", "naabu"}}) m, _ = updateModel(t, m, orchestrator.ModuleDoneEvent{Name: "subfinder", Status: "completed", Dur: time.Second}) m, _ = updateModel(t, m, orchestrator.ModuleDoneEvent{Name: "naabu", Status: "failed", Failed: true, Dur: time.Second}) @@ -232,7 +238,7 @@ func TestViewShowsSkippedModulesWithOwnBadgeAndTally(t *testing.T) { } func TestViewFindingsHeaderCountsAllFindings(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") for i := 0; i < maxFindingsShown+3; i++ { m, _ = updateModel(t, m, orchestrator.FindingEvent{ Module: "nuclei", Severity: "high", Title: "finding", Target: "https://example.com", @@ -249,7 +255,7 @@ func TestViewFindingsHeaderCountsAllFindings(t *testing.T) { } func TestAnyRunning(t *testing.T) { - m := NewScanModel(make(chan orchestrator.Event)) + m := NewScanModel(make(chan orchestrator.Event), "", "") if m.anyRunning() { t.Error("expected no running rows initially") } diff --git a/internal/tui/program_test.go b/internal/tui/program_test.go index fcf1756..696ab14 100644 --- a/internal/tui/program_test.go +++ b/internal/tui/program_test.go @@ -22,7 +22,7 @@ func TestProgramAutoQuitsOnChannelClose(t *testing.T) { done := make(chan struct{}) go func() { - _, err := tea.NewProgram(NewScanModel(ch), tea.WithInput(nil), tea.WithoutRenderer()).Run() + _, err := tea.NewProgram(NewScanModel(ch, "", ""), tea.WithInput(nil), tea.WithoutRenderer()).Run() if err != nil { t.Errorf("program error: %v", err) } diff --git a/internal/ui/style.go b/internal/ui/style.go index 3e01db0..c2a65c6 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -15,27 +15,40 @@ import ( "golang.org/x/term" ) -// ScanForge Dark palette — a dracula-inspired truecolor scheme. Every color -// carries an ANSI 0-15 fallback so output stays readable on terminals without -// truecolor support instead of degrading into mangled escape sequences. +// ScanForge Tactical palette — muted, high-contrast, pentest-grade. +// Inspired by Catppuccin Mocha / Tokyo Night / Gruvbox + ProjectDiscovery +// minimalism: one calm brand accent (blue) + semantic severity colors + +// neutral slate grays. Avoids the previous dracula neon violet/pink/cyan +// rainbow that hurt readability and looked toy-like. Every color carries an +// ANSI 0-15 fallback. var ( - colorBg = color("#1e1f29", "235") - colorBorder = color("#3d3f4d", "238") - colorDim = color("#6272a4", "8") - colorAccent = color("#8be9fd", "14") // primary brand: cyan - colorMagenta = color("#ff79c6", "13") // secondary brand - colorPurple = color("#bd93f9", "12") - colorGreen = color("#50fa7b", "10") - colorYellow = color("#f1fa8c", "11") - colorOrange = color("#ffb86c", "3") - colorRed = color("#ff5555", "9") + colorBg = color("#0f111a", "235") // deep ink, for tag foregrounds only + colorSurface = color("#1a1d29", "236") // panel surface (not directly used, lipgloss is transparent) + colorBorder = color("#2a2e3f", "238") // subtle slate border + colorDim = color("#7a8196", "8") // muted slate - secondary text + colorSubtle = color("#8b92a8", "7") // slightly lighter muted + colorText = color("#d9e1f2", "15") // primary text on dark + colorAccent = color("#7aa2f7", "12") // primary brand: calm tokyonight blue (not neon cyan) + colorMagenta = color("#7aa2f7", "12") // deprecated: mapped to accent to kill pink gradient + colorPurple = color("#5b7cc2", "4") // deprecated: muted indigo, never pink + colorGreen = color("#3fb950", "10") // success - github green, desaturated + colorYellow = color("#d29922", "11") // warning - gruvbox amber, not neon + colorOrange = color("#f0883e", "3") // abort - warm orange + colorRed = color("#f85149", "9") // danger - muted github red +) + +// suppress unused warnings for deprecated palette entries kept for compatibility +var ( + _ = colorBg + _ = colorSurface + _ = colorMagenta ) // Exported brand colors for composing panels, headers and banners. var ( Accent = colorAccent AccentCyan = colorAccent // kept for backwards compatibility - AccentMagenta = colorMagenta + AccentMagenta = colorAccent // mapped to accent - no more pink AccentGreen = colorGreen AccentYellow = colorYellow AccentOrange = colorOrange @@ -70,16 +83,20 @@ func color(hex, ansi string) lipgloss.Color { } // --------------------------------------------------------------------------- -// Plain color helpers +// Plain color helpers — single source of truth for text styling. +// Secondary is now muted slate, not pink — use Primary for brand emphasis. // --------------------------------------------------------------------------- func Primary(s string) string { return lipgloss.NewStyle().Foreground(colorAccent).Render(s) } -func Secondary(s string) string { return lipgloss.NewStyle().Foreground(colorMagenta).Render(s) } +func Secondary(s string) string { return lipgloss.NewStyle().Foreground(colorSubtle).Render(s) } +func Muted(s string) string { return lipgloss.NewStyle().Foreground(colorDim).Render(s) } +func Subtle(s string) string { return lipgloss.NewStyle().Foreground(colorSubtle).Render(s) } +func Faint(s string) string { return lipgloss.NewStyle().Foreground(colorDim).Faint(true).Render(s) } func Green(s string) string { return lipgloss.NewStyle().Foreground(colorGreen).Render(s) } func Yellow(s string) string { return lipgloss.NewStyle().Foreground(colorYellow).Render(s) } func Red(s string) string { return lipgloss.NewStyle().Foreground(colorRed).Render(s) } func Cyan(s string) string { return Primary(s) } -func Magenta(s string) string { return Secondary(s) } +func Magenta(s string) string { return Primary(s) } // pink removed: alias to primary func Orange(s string) string { return lipgloss.NewStyle().Foreground(colorOrange).Render(s) } func Purple(s string) string { return lipgloss.NewStyle().Foreground(colorPurple).Render(s) } func Gray(s string) string { return Dim(s) } @@ -87,22 +104,28 @@ func Dim(s string) string { return lipgloss.NewStyle().Foreground(colorDim func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) } func DimBold(s string) string { return lipgloss.NewStyle().Bold(true).Foreground(colorDim).Render(s) } +// AccentBold renders text in brand blue bold - for titles, targets. +func AccentBold(s string) string { + return lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Render(s) +} + // Severity colors a finding severity label: critical/high → red, medium → -// yellow, low → cyan, info → dim, anything else → plain. +// amber, low → blue, info → muted. Mirrors nuclei/httpx conventions where +// severity is the only saturated element on the line. func Severity(severity string) string { switch strings.ToLower(severity) { case "critical": return lipgloss.NewStyle().Bold(true).Foreground(colorRed).Render(severity) case "high": - return Red(severity) + return lipgloss.NewStyle().Bold(true).Foreground(colorRed).Render(severity) case "medium": - return Yellow(severity) + return lipgloss.NewStyle().Foreground(colorYellow).Render(severity) case "low": - return Cyan(severity) + return lipgloss.NewStyle().Foreground(colorAccent).Render(severity) case "info": - return Dim(severity) + return lipgloss.NewStyle().Foreground(colorDim).Render(severity) default: - return severity + return Dim(severity) } } @@ -111,68 +134,73 @@ func Severity(severity string) string { // --------------------------------------------------------------------------- var ( - tagInfo = lipgloss.NewStyle().Bold(true).Foreground(colorBg).Background(colorAccent).Padding(0, 1) - tagSuccess = lipgloss.NewStyle().Bold(true).Foreground(colorBg).Background(colorGreen).Padding(0, 1) - tagWarn = lipgloss.NewStyle().Bold(true).Foreground(colorBg).Background(colorYellow).Padding(0, 1) - tagError = lipgloss.NewStyle().Bold(true).Foreground(colorBg).Background(colorRed).Padding(0, 1) - tagSkip = lipgloss.NewStyle().Bold(true).Foreground(colorBg).Background(colorDim).Padding(0, 1) - tagAbort = lipgloss.NewStyle().Bold(true).Foreground(colorBg).Background(colorOrange).Padding(0, 1) + // Minimal bracket tags like nuclei/httpx: "[INF]" " [WRN]" — no heavy pill + // background. High contrast via bold foreground + subtle bracket dim. + tagInfo = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) + tagSuccess = lipgloss.NewStyle().Bold(true).Foreground(colorGreen) + tagWarn = lipgloss.NewStyle().Bold(true).Foreground(colorYellow) + tagError = lipgloss.NewStyle().Bold(true).Foreground(colorRed) + tagSkip = lipgloss.NewStyle().Bold(true).Foreground(colorDim) + tagAbort = lipgloss.NewStyle().Bold(true).Foreground(colorOrange) +) + +var ( + _ = tagSkip + _ = tagAbort ) func printTag(style lipgloss.Style, label, format string, args ...any) { - _, _ = fmt.Fprintf(os.Stdout, "%s %s\n", style.Render(label), fmt.Sprintf(format, args...)) + bracket := lipgloss.NewStyle().Foreground(colorDim).Render("[") + bracketClose := lipgloss.NewStyle().Foreground(colorDim).Render("]") + tag := bracket + style.Render(label) + bracketClose + _, _ = fmt.Fprintf(os.Stdout, "%s %s\n", tag, fmt.Sprintf(format, args...)) } -// Info prints a colored INFO tag followed by a message. -func Info(format string, args ...any) { printTag(tagInfo, "INFO", format, args...) } -func Success(format string, args ...any) { printTag(tagSuccess, "SUCCESS", format, args...) } -func Warn(format string, args ...any) { printTag(tagWarn, "WARNING", format, args...) } -func Error(format string, args ...any) { printTag(tagError, "ERROR", format, args...) } +// Info prints a minimal [INFO] tag followed by a message — aligns with +// ProjectDiscovery style (e.g. nuclei [INF], [WRN]) instead of pill badges. +func Info(format string, args ...any) { printTag(tagInfo, "INF", format, args...) } +func Success(format string, args ...any) { printTag(tagSuccess, "OK", format, args...) } +func Warn(format string, args ...any) { printTag(tagWarn, "WRN", format, args...) } +func Error(format string, args ...any) { printTag(tagError, "ERR", format, args...) } + +var ( + // Subtle pill badges for TUI table — background is border color, foreground + // is semantic. Much calmer than previous neon pill with Bg=accent. + badgeSuccess = lipgloss.NewStyle().Bold(true).Foreground(colorGreen).Background(colorBorder).Padding(0, 1) + badgeError = lipgloss.NewStyle().Bold(true).Foreground(colorRed).Background(colorBorder).Padding(0, 1) + badgeWarn = lipgloss.NewStyle().Bold(true).Foreground(colorYellow).Background(colorBorder).Padding(0, 1) + badgeSkip = lipgloss.NewStyle().Bold(true).Foreground(colorDim).Background(colorBorder).Padding(0, 1) + badgeAbort = lipgloss.NewStyle().Bold(true).Foreground(colorOrange).Background(colorBorder).Padding(0, 1) +) // SuccessTag renders a standalone green badge (no message, no newline). -func SuccessTag(label string) string { return tagSuccess.Render(label) } +func SuccessTag(label string) string { return badgeSuccess.Render(label) } // ErrorTag renders a standalone red badge (no message, no newline). -func ErrorTag(label string) string { return tagError.Render(label) } +func ErrorTag(label string) string { return badgeError.Render(label) } // WarnTag renders a standalone yellow badge (no message, no newline). -func WarnTag(label string) string { return tagWarn.Render(label) } +func WarnTag(label string) string { return badgeWarn.Render(label) } // SkipTag renders a standalone dim badge for modules that never ran because // an upstream dependency failed (no message, no newline). -func SkipTag(label string) string { return tagSkip.Render(label) } +func SkipTag(label string) string { return badgeSkip.Render(label) } // AbortTag renders a standalone orange badge for modules stopped by a // user-initiated abort (no message, no newline). -func AbortTag(label string) string { return tagAbort.Render(label) } +func AbortTag(label string) string { return badgeAbort.Render(label) } // --------------------------------------------------------------------------- // Gradient // --------------------------------------------------------------------------- -// Gradient renders plain text with a smooth per-character color transition -// from `from` to `to`. It requires truecolor; otherwise the text falls back -// to the primary accent. The input must not contain pre-rendered ANSI -// sequences. -func Gradient(text string, from, to lipgloss.Color) string { - if !trueColor { - return Primary(text) - } - fromRGB, okFrom := parseHex(from) - toRGB, okTo := parseHex(to) - if !okFrom || !okTo { - return Primary(text) - } - runes := []rune(text) - var b strings.Builder - for i, r := range runes { - t := 0.0 - if len(runes) > 1 { - t = float64(i) / float64(len(runes)-1) - } - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(lerpHex(fromRGB, toRGB, t))).Render(string(r))) - } - return b.String() +// Gradient is kept for API compatibility but now renders as a single calm +// brand color (no rainbow). The previous cyan→magenta per-character blend +// was the main source of the "violet, rose, bleu bizarre" complaint and is +// inconsistent with top pentest tools (nuclei, httpx, naabu) which all use +// single-color banners. Callers should prefer Primary/Bold directly. +func Gradient(text string, _, _ lipgloss.Color) string { + return lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Render(text) } func parseHex(c lipgloss.Color) ([3]int, bool) { @@ -199,6 +227,11 @@ func lerpHex(from, to [3]int, t float64) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) } +var ( + _ = parseHex + _ = lerpHex +) + // --------------------------------------------------------------------------- // Layout primitives // --------------------------------------------------------------------------- @@ -212,46 +245,58 @@ func terminalWidth() int { return 80 } -// Header renders a full-width colored bar with centered text, e.g. for the -// "ScanForge Run Started" / "Initialization Complete" banners. Width is -// measured from the real terminal instead of assumed. +// Header renders a centered, bordered pill — no longer a heavy full-width +// background bar. The bg param is treated as foreground/border accent so +// "Initialization Complete" in green still reads as success without flooding +// the terminal. Much closer to glamour/gum minimal panels. func Header(text string, bg lipgloss.Color) string { return lipgloss.NewStyle(). Bold(true). - Foreground(colorBg). - Background(bg). - Width(terminalWidth()). + Foreground(bg). + Border(lipgloss.RoundedBorder()). + BorderForeground(bg). + Padding(0, 2). Align(lipgloss.Center). Render(text) } -// PanelWith renders a rounded-border box with a colored title and a matching -// divider line. `border` colors the frame, `titleColor` the title text. +// PanelWith renders a rounded-border box with a muted divider. The border +// is always subtle slate; titleColor tints only the title text. This avoids +// the previous "all panels cyan" monotony and lets semantic borders (green/ +// yellow/red for summary) stand out while normal panels stay calm. func PanelWith(title, body string, border, titleColor lipgloss.Color) string { var content strings.Builder if title != "" { + // Title in accent, small tracking, icon already in caller. titleStyle := lipgloss.NewStyle().Bold(true).Foreground(titleColor) renderedTitle := titleStyle.Render(title) content.WriteString(renderedTitle) content.WriteString("\n") - content.WriteString(strings.Repeat("─", lipgloss.Width(renderedTitle)+2)) + // Divider in muted border, not title color — less noisy. + content.WriteString(lipgloss.NewStyle().Foreground(colorBorder).Render(strings.Repeat("─", lipgloss.Width(renderedTitle)+2))) content.WriteString("\n\n") } content.WriteString(body) + // Border is caller's semantic color only if it's green/yellow/red, else subtle. + borderStyle := colorBorder + if border == colorGreen || border == colorYellow || border == colorRed || border == colorOrange { + borderStyle = border + } return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(border). + BorderForeground(borderStyle). Padding(0, 1). Render(content.String()) } -// Panel renders a rounded-border box with an accent-colored title and frame. +// Panel renders a rounded-border box with a subtle frame and accent title. func Panel(title, body string) string { return PanelWith(title, body, colorBorder, colorAccent) } -// ProgressBar renders a block-based progress bar with a count, e.g. -// "█████░░░░░░░░░░░░░░░ 3/5". The bar turns green when complete. +// ProgressBar renders a subtle track with accent fill: "▓▓▓░░░ 3/5". +// Empty track uses dim border color, fill accent, completed uses success green. +// Thin enough to not dominate the TUI footer (vs previous heavy cyan). func ProgressBar(completed, total, width int) string { if width <= 0 { width = 20 @@ -260,48 +305,53 @@ func ProgressBar(completed, total, width int) string { total = completed } if total <= 0 { - // Nothing to count (failed or empty run): render an empty bar instead - // of dividing by zero. - return fmt.Sprintf("%s 0/0", strings.Repeat("░", width)) + return Dim(strings.Repeat("░", width)) + Dim(" 0/0") } if completed > total { completed = total } filled := int(float64(completed) / float64(total) * float64(width)) - bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) - barStyle := lipgloss.NewStyle().Foreground(colorAccent) - if completed == total { - barStyle = lipgloss.NewStyle().Foreground(colorGreen) + filledBar := strings.Repeat("━", filled) + emptyBar := strings.Repeat("─", width-filled) + var bar string + if completed == total && total > 0 { + bar = lipgloss.NewStyle().Foreground(colorGreen).Render(filledBar) + lipgloss.NewStyle().Foreground(colorBorder).Render(emptyBar) + } else { + bar = lipgloss.NewStyle().Foreground(colorAccent).Render(filledBar) + lipgloss.NewStyle().Foreground(colorBorder).Render(emptyBar) } - return fmt.Sprintf("%s %d/%d", barStyle.Render(bar), completed, total) + count := Dim(fmt.Sprintf("%d/%d", completed, total)) + return bar + " " + count } -// WaveHeader renders a full-width section header for an execution wave, e.g. -// "────── Wave 1 · subfinder, dnsx ──────". +// WaveHeader renders a calm wave divider: "─ Wave 1 · subfinder, dnsx ─". +// Uses dim rules and accent label, not heavy full-width bars. func WaveHeader(wave int, modules string) string { label := fmt.Sprintf("Wave %d", wave) if modules != "" { - label += " · " + modules + // modules in muted, wave in accent + label = lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Render(fmt.Sprintf("Wave %d", wave)) + + Dim(" · "+modules) + } else { + label = lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Render(label) } - head := lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Render(label) width := terminalWidth() - fill := width - lipgloss.Width(head) - 4 + fill := (width - lipgloss.Width(label) - 4) / 2 if fill < 2 { fill = 2 } rule := lipgloss.NewStyle().Foreground(colorBorder).Render(strings.Repeat("─", fill)) - return rule + " " + head + " " + rule + return Dim(rule+" ") + label + Dim(" "+rule) } -// CommandLine renders a shell command dimmed with a colored prompt, as used -// by dry runs and the commands log. +// CommandLine renders a shell command dimmed with a muted prompt. func CommandLine(command string) string { - prompt := lipgloss.NewStyle().Bold(true).Foreground(colorMagenta).Render("$") - return prompt + " " + lipgloss.NewStyle().Foreground(colorDim).Render(command) + prompt := lipgloss.NewStyle().Foreground(colorDim).Render("›") + return prompt + " " + lipgloss.NewStyle().Foreground(colorSubtle).Render(command) } -// Table renders a bordered, header-having table, replacing -// pterm.DefaultTable.WithHasHeader().WithBoxed(). +// Table renders a bordered table with muted header and subtle grid — matches +// nuclei/httpx minimal tables where header is dim, not saturated. Keeps data +// readable when many modules are listed. func Table(headers []string, rows [][]string) string { t := table.New(). Border(lipgloss.RoundedBorder()). @@ -311,9 +361,9 @@ func Table(headers []string, rows [][]string) string { StyleFunc(func(row, _ int) lipgloss.Style { style := lipgloss.NewStyle().Padding(0, 1) if row == table.HeaderRow { - return style.Bold(true).Foreground(colorAccent) + return style.Bold(true).Foreground(colorDim) } - return style + return style.Foreground(colorText) }) return t.Render() } diff --git a/tests/install_manifest_test.sh b/tests/install_manifest_test.sh new file mode 100755 index 0000000..6be91e8 --- /dev/null +++ b/tests/install_manifest_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail=0 +while IFS='=' read -r key version; do + case "$key" in ''|'#'*) continue ;; esac + case "$key" in + SECLISTS_VERSION) [[ "$version" =~ ^[0-9]+\.[0-9]+$ ]] || fail=1 ;; + SECLISTS_DNS_SHA256|MASSDNS_SOURCE_SHA256) [[ "$version" =~ ^[0-9a-f]{64}$ ]] || fail=1 ;; + *_VERSION) [[ "$version" =~ ^v[0-9] ]] || fail=1 ;; + *) printf 'Unknown .tools-version key: %s\n' "$key" >&2; fail=1 ;; + esac + for consumer in install.sh install.ps1 Dockerfile; do + if ! grep -q "$key" "$consumer"; then + printf '%s does not consume %s\n' "$consumer" "$key" >&2 + fail=1 + fi + done +done < .tools-version + +if grep -REn '^[[:space:]]*(SUBFINDER_VERSION|DNSX_VERSION|HTTPX_VERSION|NAABU_VERSION|KATANA_VERSION|NUCLEI_VERSION|TLSX_VERSION|GAU_VERSION|FFUF_VERSION|SHUFFLEDNS_VERSION|WAFW00F_VERSION|MASSDNS_VERSION|SECLISTS_VERSION)=' \ + --binary-files=without-match --exclude=.tools-version --exclude=scanforge --exclude-dir=.git .; then + printf 'Pinned tool versions must only be assigned in .tools-version\n' >&2 + fail=1 +fi + +exit "$fail" diff --git a/tests/install_ps1_test.ps1 b/tests/install_ps1_test.ps1 new file mode 100755 index 0000000..ae96ff6 --- /dev/null +++ b/tests/install_ps1_test.ps1 @@ -0,0 +1,43 @@ +$ErrorActionPreference = "Stop" +$env:SCANFORGE_INSTALLER_TESTING = "1" +. (Join-Path $PSScriptRoot "..\install.ps1") + +$tests = 0 +function Assert-Equal($Actual, $Expected) { + $script:tests++ + if ($Actual -ne $Expected) { throw "Expected '$Expected', got '$Actual'" } + Write-Host "ok $script:tests" +} + +Assert-Equal (Get-ScanForgeArchitecture ([System.Runtime.InteropServices.Architecture]::X64)) "amd64" +try { + Get-ScanForgeArchitecture ([System.Runtime.InteropServices.Architecture]::Arm64) | Out-Null + throw "Expected Arm64 to be rejected" +} catch { + if ($_.Exception.Message -eq "Expected Arm64 to be rejected") { throw } +} +$tests++; Write-Host "ok $tests" + +$digest = "a" * 64 +$content = "$digest scanforge_1.0.0_windows_amd64.zip`n" +Assert-Equal (Get-ChecksumEntry $content "scanforge_1.0.0_windows_amd64.zip") $digest +Assert-Equal (Get-ChecksumEntry $content "missing.zip") $null + +$tempFile = Join-Path ([System.IO.Path]::GetTempPath()) ("scanforge-checksum-test-" + [guid]::NewGuid().ToString("N")) +try { + [System.IO.File]::WriteAllText($tempFile, "payload") + $actual = (Get-FileHash -LiteralPath $tempFile -Algorithm SHA256).Hash.ToLowerInvariant() + Assert-FileChecksum $tempFile $actual "test" | Out-Null + $tests++; Write-Host "ok $tests" + try { + Assert-FileChecksum $tempFile ("0" * 64) "test" | Out-Null + throw "Expected checksum mismatch" + } catch { + if ($_.Exception.Message -eq "Expected checksum mismatch") { throw } + } + $tests++; Write-Host "ok $tests" +} finally { + Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue +} + +Write-Host "1..$tests" diff --git a/tests/install_sh_test.sh b/tests/install_sh_test.sh new file mode 100755 index 0000000..f60331f --- /dev/null +++ b/tests/install_sh_test.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export SCANFORGE_INSTALLER_TESTING=1 +# shellcheck source=../install.sh +# shellcheck disable=SC1091 +source "${REPO_ROOT}/install.sh" + +TEST_TEMP="$(mktemp -d "${TMPDIR:-/tmp}/scanforge-install-test.XXXXXXXX")" +trap 'cleanup; rm -rf -- "$TEST_TEMP"' EXIT +TESTS=0 + +assert_equal() { + TESTS=$((TESTS + 1)) + if [ "$1" != "$2" ]; then + printf 'not ok %d - expected %q, got %q\n' "$TESTS" "$2" "$1" >&2 + exit 1 + fi + printf 'ok %d\n' "$TESTS" +} + +detect_os Linux +assert_equal "$OS" linux +detect_os Darwin +assert_equal "$OS" darwin + +OS=linux +detect_arch x86_64 +assert_equal "$ARCH" amd64 +detect_arch aarch64 +assert_equal "$ARCH" arm64 +if (detect_arch i686) >/dev/null 2>&1; then + printf 'expected i686 detection to fail\n' >&2 + exit 1 +fi +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" +if (detect_arch riscv64) >/dev/null 2>&1; then + printf 'expected unknown architecture detection to fail\n' >&2 + exit 1 +fi +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" + +mkdir "${TEST_TEMP}/bin" +touch "${TEST_TEMP}/bin/pacman" +chmod +x "${TEST_TEMP}/bin/pacman" +old_path="$PATH" +PATH="${TEST_TEMP}/bin" +OS=linux +detect_package_manager +PATH="$old_path" +assert_equal "$PACKAGE_MANAGER" pacman + +load_tool_versions +assert_equal "$SUBFINDER_VERSION" v2.15.0 +assert_equal "$WAFW00F_VERSION" v2.4.2 + +arch_packages="$(packages_for_manager pacman)" +assert_equal "$arch_packages" $'nmap\nchromium\ngo\npython-pipx\nbase-devel' +if printf '%s\n' "$arch_packages" | grep -Eq 'whatweb|wafw00f|massdns'; then + printf 'AUR-only packages must not be passed to pacman\n' >&2 + exit 1 +fi +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" + +mkdir "${TEST_TEMP}/empty-bin" +if (PATH="${TEST_TEMP}/empty-bin" file_sha256 "${TEST_TEMP}/payload") >/dev/null 2>&1; then + printf 'expected missing SHA-256 implementation to fail\n' >&2 + exit 1 +fi +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" + +printf 'verified payload\n' > "${TEST_TEMP}/payload" +digest="$(file_sha256 "${TEST_TEMP}/payload")" +verify_checksum "${TEST_TEMP}/payload" "$digest" test >/dev/null +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" +bad_digest="$(printf '%064d' 0)" +if (verify_checksum "${TEST_TEMP}/payload" "$bad_digest" test) >/dev/null 2>&1; then + printf 'expected checksum mismatch to fail\n' >&2 + exit 1 +fi +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" + +printf '%s artifact.tar.gz\n' "$digest" > "${TEST_TEMP}/checksums.txt" +assert_equal "$(checksum_entry "${TEST_TEMP}/checksums.txt" artifact.tar.gz)" "$digest" +if (checksum_entry "${TEST_TEMP}/checksums.txt" missing.tar.gz) >/dev/null 2>&1; then + printf 'expected missing checksum entry to fail\n' >&2 + exit 1 +fi +TESTS=$((TESTS + 1)); printf 'ok %d\n' "$TESTS" + +printf '1..%d\n' "$TESTS"