-
Notifications
You must be signed in to change notification settings - Fork 0
fix: make bounty module portable across platforms and LLM providers #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,29 @@ | ||
| """LLM-callable security tools — recon, scan, report. | ||
|
|
||
| Every tool is scope-enforced and rate-limited so agents cannot go off-target. | ||
| CLI tools are invoked via subprocess; the Go ``httpx`` binary is called via | ||
| its full path (``/opt/homebrew/bin/httpx``) to avoid collision with the | ||
| Python ``httpx`` pip package. | ||
| CLI tools are invoked via subprocess; the Go ``httpx`` binary is resolved via | ||
| ``shutil.which`` to work on any platform. The ``HTTPX_BIN`` environment | ||
| variable can override the binary path explicitly, and ``FFUF_WORDLIST`` can | ||
| override the default wordlist used by ``scan_ffuf``. | ||
|
|
||
| ⚠️ Contenu généré par IA — validation humaine requise avant utilisation. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| import os | ||
| import shutil | ||
| import socket | ||
| import ssl | ||
| import subprocess | ||
| import tempfile | ||
| import time | ||
| from dataclasses import dataclass, field | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Any, Callable | ||
| from typing import Any | ||
|
|
||
| from firm.bounty.scope import ScopeEnforcer | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Rate limiter (token-bucket per target) | ||
| # --------------------------------------------------------------------------- | ||
|
|
@@ -81,8 +82,60 @@ def _run(cmd: list[str], timeout: int = 120) -> str: | |
| return f"[TOOL NOT FOUND: {cmd[0]}]" | ||
|
|
||
|
|
||
| # Path to Go httpx binary (avoids Python httpx-cli collision) | ||
| _HTTPX_BIN = "/opt/homebrew/bin/httpx" | ||
| def _resolve_httpx_bin() -> str: | ||
| """Return the path to the Go httpx binary. | ||
|
|
||
| Resolution order (first match wins): | ||
| 1. ``HTTPX_BIN`` environment variable. | ||
| 2. ``httpx-toolkit`` on ``$PATH`` (common Linux package name). | ||
| 3. ``httpx`` on ``$PATH`` (generic name, may collide with Python package). | ||
| 4. macOS Homebrew default ``/opt/homebrew/bin/httpx``. | ||
|
|
||
| If none is found the first available name is returned anyway so that | ||
| the caller receives a useful ``[TOOL NOT FOUND]`` message rather than | ||
| a silent failure. | ||
| """ | ||
| env_override = os.environ.get("HTTPX_BIN", "").strip() | ||
| if env_override: | ||
| return env_override | ||
| for candidate in ("httpx-toolkit", "httpx"): | ||
| found = shutil.which(candidate) | ||
| if found: | ||
| return found | ||
| # macOS Homebrew fallback | ||
| homebrew_path = "/opt/homebrew/bin/httpx" | ||
| if Path(homebrew_path).exists(): | ||
| return homebrew_path | ||
| return "httpx-toolkit" # will produce a clear TOOL NOT FOUND message | ||
|
|
||
|
|
||
| _DEFAULT_FFUF_WORDLISTS: list[str] = [ | ||
| "/usr/share/seclists/Discovery/Web-Content/common.txt", # Debian/Ubuntu | ||
| "/usr/share/SecLists/Discovery/Web-Content/common.txt", # some distros | ||
| str(Path.home() / "SecLists/Discovery/Web-Content/common.txt"), # macOS/user install | ||
| "/opt/homebrew/share/seclists/Discovery/Web-Content/common.txt", # Homebrew | ||
| ] | ||
|
Comment on lines
+112
to
+117
|
||
|
|
||
|
|
||
| def _resolve_ffuf_wordlist(wordlist: str = "") -> str | None: | ||
| """Return the first available wordlist path. | ||
|
|
||
| Resolution order: | ||
| 1. Explicit ``wordlist`` argument (if non-empty and the file exists). | ||
| 2. ``FFUF_WORDLIST`` environment variable. | ||
| 3. Platform-specific default locations (see ``_DEFAULT_FFUF_WORDLISTS``). | ||
|
|
||
| Returns ``None`` when no wordlist can be found. | ||
| """ | ||
| if wordlist: | ||
| return wordlist if Path(wordlist).exists() else None | ||
| env_wl = os.environ.get("FFUF_WORDLIST", "").strip() | ||
| if env_wl: | ||
| return env_wl if Path(env_wl).exists() else None | ||
| for path in _DEFAULT_FFUF_WORDLISTS: | ||
| if Path(path).exists(): | ||
| return path | ||
| return None | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
|
|
@@ -123,7 +176,7 @@ def recon_tech(target: str) -> str: | |
| return f"BLOCKED: {target} is not in scope." | ||
| if not limiter.allow(target): | ||
| return "RATE LIMITED — retry later." | ||
| httpx_bin = _HTTPX_BIN if Path(_HTTPX_BIN).exists() else "httpx-toolkit" | ||
| httpx_bin = _resolve_httpx_bin() | ||
| return _run( | ||
| [httpx_bin, "-u", f"https://{target}", | ||
| "-tech-detect", "-status-code", "-title", "-silent"], | ||
|
|
@@ -201,13 +254,9 @@ def scan_ffuf(target: str, wordlist: str = "", path: str = "/FUZZ") -> str: | |
| return f"BLOCKED: {target} is not in scope." | ||
| if not limiter.allow(target): | ||
| return "RATE LIMITED — retry later." | ||
| wl = wordlist or "/usr/share/seclists/Discovery/Web-Content/common.txt" | ||
| if not Path(wl).exists(): | ||
| wl_alt = Path.home() / "SecLists/Discovery/Web-Content/common.txt" | ||
| if wl_alt.exists(): | ||
| wl = str(wl_alt) | ||
| else: | ||
| return "[WORDLIST NOT FOUND — install SecLists]" | ||
| wl = _resolve_ffuf_wordlist(wordlist) | ||
| if wl is None: | ||
| return "[WORDLIST NOT FOUND — set FFUF_WORDLIST or install SecLists]" | ||
| url = f"https://{target}{path}" | ||
| return _run( | ||
| ["ffuf", "-u", url, "-w", wl, | ||
|
|
@@ -278,8 +327,6 @@ def scan_ssl(target: str) -> str: | |
| return f"BLOCKED: {target} is not in scope." | ||
| if not limiter.allow(target): | ||
| return "RATE LIMITED — retry later." | ||
| import ssl | ||
| import socket | ||
| findings: list[str] = [] | ||
| try: | ||
| ctx = ssl.create_default_context() | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -6,19 +6,21 @@ | |||||
| ⚠️ Contenu généré par IA — validation humaine requise avant utilisation. | ||||||
| """ | ||||||
|
|
||||||
| from unittest.mock import patch | ||||||
|
|
||||||
| import pytest | ||||||
| from unittest.mock import patch, MagicMock | ||||||
|
|
||||||
| from firm.bounty.scope import Asset, AssetType, ScopeEnforcer, TargetScope | ||||||
| from firm.bounty.tools.scanner import ( | ||||||
| RateLimiter, | ||||||
| _resolve_ffuf_wordlist, | ||||||
| _resolve_httpx_bin, | ||||||
| make_bounty_tools, | ||||||
| make_recon_tools, | ||||||
| make_report_tools, | ||||||
| make_scanning_tools, | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| # --------------------------------------------------------------------------- | ||||||
| # Fixtures | ||||||
| # --------------------------------------------------------------------------- | ||||||
|
|
@@ -154,3 +156,76 @@ def test_report_generate(self): | |||||
| ) | ||||||
| assert "# XSS in search" in result | ||||||
| assert "CWE-79" in result | ||||||
|
|
||||||
|
|
||||||
| # --------------------------------------------------------------------------- | ||||||
| # Binary / wordlist resolution | ||||||
| # --------------------------------------------------------------------------- | ||||||
|
|
||||||
| class TestResolveHttpxBin: | ||||||
| def test_env_var_takes_priority(self, monkeypatch): | ||||||
| monkeypatch.setenv("HTTPX_BIN", "/custom/httpx") | ||||||
| assert _resolve_httpx_bin() == "/custom/httpx" | ||||||
|
|
||||||
| def test_env_var_empty_falls_through(self, monkeypatch, tmp_path): | ||||||
|
||||||
| def test_env_var_empty_falls_through(self, monkeypatch, tmp_path): | |
| def test_env_var_empty_falls_through(self, monkeypatch): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_resolve_model()is called at module import time for each agent inBOUNTY_AGENTS. This means env var overrides (FIRM_*_MODEL,FIRM_DEFAULT_MODEL) are "frozen" at first import. For the documented CLI workflow (env vars set beforefirm bounty ...), this works becausecli.pyuses lazy imports. However, programmatic callers who set env vars and then callcreate_bounty_firm()after the module has already been imported won't see the updated models. Consider deferring model resolution intocreate_bounty_firm()so it always reads current env vars, or document this limitation explicitly.