diff --git a/AGENTS.md b/AGENTS.md index ac320a4d..075b3fa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ Each example is self-contained and demonstrates specific patterns and capabiliti - `arxiv_search/` - Academic paper search via the public arXiv API - `bocha_search/` - BoCha web search - `calculator/` - Mathematical computation tool +- `darkmoon_pentest/` - Run Darkmoon autonomous pentests and read campaigns and findings from a self-hosted Darkmoon dashboard (Pro) - `datetime_tool/` - Timezone-aware now / parse / convert / shift / diff - `fia_signals/` - Crypto market intelligence (regime, signals, yields, gas, trending, wallet risk) - `hackernews_search/` - Hacker News search via the free Algolia API diff --git a/README.md b/README.md index 270f5588..6fe4a109 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ Atomic Forge is a collection of tools that can be used with Atomic Agents to ext - arXiv Search - BoCha Search - Calculator +- Darkmoon Pentest - DateTime - Fía Signals - Hacker News Search diff --git a/atomic-forge/README.md b/atomic-forge/README.md index d34c5a0f..cc2d864d 100644 --- a/atomic-forge/README.md +++ b/atomic-forge/README.md @@ -16,6 +16,7 @@ The Atomic Forge project includes the following tools: - [arXiv Search](/atomic-forge/tools/arxiv_search/README.md) — search the free arXiv public API for academic papers. - [BoCha Search](/atomic-forge/tools/bocha_search/README.md) - [Calculator](/atomic-forge/tools/calculator/README.md) +- [Darkmoon Pentest](/atomic-forge/tools/darkmoon_pentest/README.md) — run Darkmoon pentests and read campaigns and findings from a self-hosted Darkmoon dashboard (Pro). - [DateTime](/atomic-forge/tools/datetime_tool/README.md) — timezone-aware now/parse/convert/shift/diff. - [Fía Signals](/atomic-forge/tools/fia_signals/README.md) - [Hacker News Search](/atomic-forge/tools/hackernews_search/README.md) — search HN stories, comments, Show HN, Ask HN via the free Algolia API. diff --git a/atomic-forge/tools/darkmoon_pentest/.coveragerc b/atomic-forge/tools/darkmoon_pentest/.coveragerc new file mode 100644 index 00000000..c45136ef --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = tool +omit = */tests/* + +[report] +exclude_lines = + if __name__ == "__main__": +show_missing = True diff --git a/atomic-forge/tools/darkmoon_pentest/README.md b/atomic-forge/tools/darkmoon_pentest/README.md new file mode 100644 index 00000000..da192fd1 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/README.md @@ -0,0 +1,97 @@ +# Darkmoon Pentest Tool + +## Overview +Runs autonomous penetration tests with [Darkmoon](https://github.com/ASCIT31/Dark-Moon) (GPL-3.0, an LLM orchestrates specialist agents and offensive tools and proves findings with real exploits) and reads campaigns and findings back, through the Dashboard API of a Darkmoon instance that you operate yourself. There is no public hosted endpoint. + +The tool logs in with `POST /api/v1/auth/login`, then uses the returned token for `POST /api/v1/run/campaign`, `GET /api/v1/run/logs/{run_id}`, `GET /api/v1/campaigns` and `GET /api/v1/vulnerabilities`. + +**Open source vs Pro:** the Darkmoon engine and CLI are open source. The web dashboard and its API, which this tool talks to, are part of Darkmoon Pro. Without a dashboard, use the open source CLI and read its JSON or SARIF output instead. + +**Supported API version:** Darkmoon Pro Dashboard API major version `1` (OpenAPI app version `1.0.0`, reported as `api_version` by `GET /api/v1/system/info`, `contract_version` `"1"`), as shipped with Darkmoon Pro v1.4.x. The tool reads `/api/v1/system/info` first and fails closed on any incompatible or malformed contract: a present (`HTTP 200`) response must be a JSON object whose `api_version` major is `1` **and** whose `contract_version` is `"1"`. A garbage `api_version`, a missing field, a different major, or a `contract_version` other than `"1"` is rejected before authenticating. The "treat as unversioned and accept" fallback applies only when the route is **absent** (`HTTP 404`), for older instances that predate `/system/info`. The response shapes the tool depends on are pinned by the JSON contract fixtures in `tests/fixtures/` (see the README there for how they were derived and their limits). + +Only run assessments against systems you own or are explicitly authorised to test. Findings can include false positives and must be reviewed by a human. + +## Prerequisites and Dependencies +- Python 3.12 or later +- `atomic-agents` +- `pydantic` +- `aiohttp` +- A running Darkmoon instance with the dashboard enabled, and a dashboard user + +## Installation +1. Use the Atomic Assembler CLI: run `atomic` and pick `darkmoon_pentest`. +2. Or copy the `tool/` folder directly into your project. + +## Configuration +- `base_url` (str): Dashboard API base URL, e.g. `http://localhost:8000`. Falls back to `DARKMOON_BASE_URL`. +- `username` (str): dashboard username. Falls back to `DARKMOON_USERNAME`. +- `password` (str): dashboard password. Falls back to `DARKMOON_PASSWORD`. +- `timeout` (float): HTTP timeout per request in seconds (default 60). +- `run_timeout` (float): maximum seconds to wait for a pentest run to finish (default 1800). +- `poll_interval` (float): seconds between run status checks (default 5). + +## Input & Output Structure + +### Input Schema +- `action` (str): `run_pentest`, `list_campaigns` or `get_findings`. +- `target` (str): host, URL or scope to assess. Required for `run_pentest`. +- `campaign_id` (str): campaign to read. Required for `get_findings`. +- `wait_for_completion` (bool): for `run_pentest`, wait for the run to finish and report its status (not its findings; default `True`). +- `program` (str), `focus` (list[str]), `min_severity` (`info`, `low`, `medium`, `high`, `critical`): optional run settings. + +### Output Schema +- `action`, `run_id`, `run_status` (`started`, `completed` or `timed_out`), `campaign_id` (set only by `get_findings`), `timed_out` +- `campaigns`: list of `DarkmoonCampaign` (`id`, `date`) +- `total`, `stats`: finding count and the severity statistics reported by the dashboard +- `findings`: list of `DarkmoonFinding` (`title`, `severity`, `cvss_score`, `category`, `status`, `endpoint`, `cve`, `description`, `remediation`) + +#### Why findings are not auto-attributed to a run + +`run_pentest` returns the run's **status**, not its findings. To read findings, call `list_campaigns` and then `get_findings` with the campaign id you choose. + +This is deliberate. The public contract exposes **no verifiable run→campaign key**. The campaign record (built in `mcp/api/live_push.py`, the `campaign = {...}` dict) carries only `id / project_id / target_id / session_id / is_subagent / date / status / stats / ...` — no `run_id`, no owner, and no full session id. Its `session_id` is the dashboard MCP server's independent `SESSION_ID = uuid4().hex[:8]` (`mcp/src/server.py`), and the Pro run-start response carries no campaign id. The only linkage the server itself performs is a best-effort reconciliation sweep that matches a campaign's 8-char `session_id` as a substring window of a run's parent session id — a heuristic, not an authoritative ownership proof. + +Matching on a session-id suffix/window therefore cannot establish ownership: a run whose session merely *contains* an 8-char hex sequence that happens to equal some old campaign's `session_id` would be paired with that unrelated campaign and return the wrong findings. Rather than claim a correlation the contract cannot back, the tool does not attribute findings to a run at all; it reports whether the run `started`, `completed`, or `timed_out`, and leaves campaign selection explicit. A failed run is still surfaced as a failure (see below), never as an empty result. + +Failure and status handling, all raised as exceptions rather than returned as an empty result: +- `DarkmoonRunError`: the run ended with `run_error`, was aborted, or opencode exited non-zero. The message includes the run id. +- `DarkmoonAPIError`: an HTTP error status from the API (`.status` holds the code). Only an HTTP 404 on the run log is treated as "the run has not written its log yet". +- `DarkmoonSchemaError`: a response did not have the expected shape (missing `data` list, missing `run_id` or `token`, a finding without a title, `cvss_score` outside 0 to 10, a negative or non-integer `total`, a `stats` bucket that is not an object — including an explicit `null` — or a bucket count that is negative or non-integer, or a present-but-incompatible/malformed version contract). Version values and stats bucket names are scrubbed of secrets before they appear in the message. +- `DarkmoonRedirectError`: the API tried to redirect a request to a different origin, or returned an unusable/looping redirect. The tool refuses it so credentials are never replayed off `base_url`. + +`run_timeout` is a hard deadline: both the sleeps between polls and each poll request are capped by the time that remains, so the call returns within about `run_timeout` seconds. A request's whole budget (not a fresh budget per redirect hop) is shared across every hop and rechecked before a terminal result is accepted, so a chain of redirects cannot overrun the deadline. If the wait times out, `run_status` is `timed_out`, `timed_out` is `True`, and the run keeps going on the Darkmoon side (read it later with `list_campaigns` and `get_findings`). + +**Credential and secret safety.** TLS certificates are always verified. Redirects are resolved by hand (the HTTP client's automatic redirect following is disabled): a `3xx` whose `Location` points to a different origin is refused rather than followed, so a hostile or misconfigured `307` can never replay the login POST body or the bearer token to another host; same-origin redirects (for example a trailing-slash `307`) are followed, bounded to a few hops. The dashboard password is excluded from the config and tool `repr`, and both the password and the bearer token are scrubbed from every raised exception message, so a verbose or hostile API `detail` or run-error text cannot leak them. + +## Tests +`uv run pytest atomic-forge/tools/darkmoon_pentest` runs the suite against an in-process imitation of the Dashboard API whose response bodies are the contract fixtures. The imitation server and the fixtures were written from the Dashboard API source and the published `@darkmoon_ai/client` 0.2.0, so they pin the shapes this tool expects but do not replace a run against a real Pro instance. + +## Usage + +```python +from tool.darkmoon_pentest import ( + DarkmoonPentestTool, + DarkmoonPentestToolConfig, + DarkmoonPentestToolInputSchema, +) + +tool = DarkmoonPentestTool( + config=DarkmoonPentestToolConfig( + base_url="http://localhost:8000", + username="analyst", + password="your-dashboard-password", + ) +) + +campaigns = tool.run(DarkmoonPentestToolInputSchema(action="list_campaigns")) +report = tool.run(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=campaigns.campaigns[0].id)) + +for finding in report.findings: + print(finding.severity, finding.title, finding.endpoint) +``` + +## Contributing +PRs welcome. See the main repo `CONTRIBUTING.md`. + +## License +Same as the main Atomic Agents project. diff --git a/atomic-forge/tools/darkmoon_pentest/pyproject.toml b/atomic-forge/tools/darkmoon_pentest/pyproject.toml new file mode 100644 index 00000000..1f82e07c --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["tool"] + +[project] +name = "darkmoon-pentest" +version = "1.0.0" +description = "Run Darkmoon autonomous pentests and read campaigns and findings from a self-hosted Darkmoon dashboard, as an Atomic Agents tool" +readme = "README.md" +authors = [ + { name = "Darkmoon" } +] +requires-python = ">=3.12" +dependencies = [ + "atomic-agents", + "pydantic>=2.10.3,<3.0.0", + "aiohttp>=3.9.0,<4.0.0", +] + +[dependency-groups] +dev = [ + "coverage>=7.6.1,<8.0.0", + "pytest>=8.3.3,<9.0.0", + "pytest-asyncio>=0.23.5,<1.0.0", + "pytest-cov>=5.0.0,<6.0.0", + "python-dotenv>=1.0.0,<2.0.0", + "rich>=13.7.0,<14.0.0", +] + +[tool.uv.sources] +atomic-agents = { workspace = true } diff --git a/atomic-forge/tools/darkmoon_pentest/requirements.txt b/atomic-forge/tools/darkmoon_pentest/requirements.txt new file mode 100644 index 00000000..469c7e98 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/requirements.txt @@ -0,0 +1,3 @@ +atomic-agents>=2.0.0,<3.0.0 +pydantic>=2.10.3,<3.0.0 +aiohttp>=3.9.0,<4.0.0 diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/README.md b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/README.md new file mode 100644 index 00000000..6f6d783c --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/README.md @@ -0,0 +1,18 @@ +# Contract fixtures + +JSON bodies in the exact shapes the Darkmoon Pro Dashboard API (OpenAPI app version 1.0.0, `GET /api/v1/system/info` +`contract_version` "1") returns for the routes this tool uses. They were written from the Dashboard API route +implementations (`mcp/api/routes_run.py`, `routes_campaigns.py`, `routes_vulnerabilities.py`, `routes_system.py`) and +cross-checked against the published `@darkmoon_ai/client` 0.2.0 Pro backend and schema. They are not recordings of a live +instance, and all identifiers and hostnames in them are synthetic. + +| File | Route | +| --- | --- | +| `login.json` | `POST /api/v1/auth/login` | +| `system_info.json` | `GET /api/v1/system/info` | +| `run_campaign.json` | `POST /api/v1/run/campaign` (returns `run_id`, never a campaign id) | +| `run_logs_completed.json` | `GET /api/v1/run/logs/{run_id}`, run finished | +| `run_logs_error.json` | same, run ended with `run_error` | +| `run_logs_aborted.json` | same, run ended with `run_completed` and `aborted: true` | +| `campaigns.json` | `GET /api/v1/campaigns` | +| `vulnerabilities.json` | `GET /api/v1/vulnerabilities?campaign_id=...` | diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/campaigns.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/campaigns.json new file mode 100644 index 00000000..158741c0 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/campaigns.json @@ -0,0 +1,4 @@ +{"total": 2, "data": [ + {"id": "camp_20260101_01d5e551", "project_id": "proj_1", "target_id": "tgt_old", "session_id": "01d5e551", "date": "2026-01-01T09:00:00+00:00", "status": "completed", "is_subagent": false, "overall_risk": "low", "stats": {"total_findings": 0}, "report_path": "/reports/pentest_report_old.example_20260101-0900.md"}, + {"id": "camp_20261004_5f3a9c1e", "project_id": "proj_2", "target_id": "tgt_new", "session_id": "5f3a9c1e", "date": "2026-10-04T10:15:30+00:00", "status": "completed", "is_subagent": false, "overall_risk": "high", "stats": {"total_findings": 1, "high": 1}, "report_path": "/reports/pentest_report_target.example_20261004-101530.md"} +]} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/login.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/login.json new file mode 100644 index 00000000..5decd3d5 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/login.json @@ -0,0 +1 @@ +{"token": "eyJhbGciOiJIUzI1NiJ9.fixture.signature", "must_change_password": false, "user": {"username": "analyst", "role": "admin"}} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_campaign.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_campaign.json new file mode 100644 index 00000000..37933efe --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_campaign.json @@ -0,0 +1 @@ +{"run_id": "run_20261004_101500_a1b2c3d4", "pid": 4242, "command": "Run a pentest on target.example"} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_aborted.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_aborted.json new file mode 100644 index 00000000..bd8ef95a --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_aborted.json @@ -0,0 +1,5 @@ +{"total": 3, "data": [ + {"type": "run_started", "run_id": "run_20261004_101500_a1b2c3d4", "command": "Run a pentest on target.example", "timestamp": "2026-10-04T10:15:00+00:00"}, + {"type": "step_start", "sessionID": "ses_2a9e5f3a9c1eAbCdEf0123", "timestamp": 1790000001000}, + {"type": "run_completed", "run_id": "run_20261004_101500_a1b2c3d4", "exit_code": -15, "aborted": true, "timestamp": "2026-10-04T10:20:00+00:00"} +]} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_completed.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_completed.json new file mode 100644 index 00000000..21a8f5e9 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_completed.json @@ -0,0 +1,6 @@ +{"total": 4, "data": [ + {"type": "run_started", "run_id": "run_20261004_101500_a1b2c3d4", "command": "Run a pentest on target.example", "timestamp": "2026-10-04T10:15:00+00:00"}, + {"type": "step_start", "sessionID": "ses_2a9e5f3a9c1eAbCdEf0123", "timestamp": 1790000001000, "part": {"type": "step-start"}}, + {"type": "subagent_text", "text": "scanning", "agent": "recon"}, + {"type": "run_completed", "run_id": "run_20261004_101500_a1b2c3d4", "exit_code": 0, "timestamp": "2026-10-04T10:42:00+00:00"} +]} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_error.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_error.json new file mode 100644 index 00000000..efed2def --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_error.json @@ -0,0 +1,5 @@ +{"total": 3, "data": [ + {"type": "run_started", "run_id": "run_20261004_101500_a1b2c3d4", "command": "Run a pentest on target.example", "timestamp": "2026-10-04T10:15:00+00:00"}, + {"type": "step_start", "sessionID": "ses_2a9e5f3a9c1eAbCdEf0123", "timestamp": 1790000001000}, + {"type": "run_error", "run_id": "run_20261004_101500_a1b2c3d4", "error": "provider rate limit exceeded", "timestamp": "2026-10-04T10:16:00+00:00"} +]} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/system_info.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/system_info.json new file mode 100644 index 00000000..7cce9170 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/system_info.json @@ -0,0 +1 @@ +{"edition": "pro", "api_version": "1.0.0", "product_version": "v1.4.0", "product_built_at": "2026-09-30T08:00:00Z", "contract_version": "1", "capabilities": {}, "features": {"auth": true, "sse_progress": true, "remediation": true, "scheduler": true}} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/fixtures/vulnerabilities.json b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/vulnerabilities.json new file mode 100644 index 00000000..3e678691 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/fixtures/vulnerabilities.json @@ -0,0 +1,3 @@ +{"total": 1, "stats": {"by_severity": {"high": 1}, "by_category": {"sql_injection": 1}, "by_status": {"confirmed": 1}}, "data": [ + {"id": "vuln_001", "campaign_id": "camp_20261004_5f3a9c1e", "title": "SQL injection in /api/items", "severity": "high", "cvss_score": 8.1, "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", "category": "sql_injection", "status": "confirmed", "endpoint": "https://target.example/api/items?id=1", "cve": null, "description": "The id parameter is concatenated into a query.", "remediation": "Use parameterised queries.", "mitre_attack_id": "T1190", "evidence": {"commands": ["sqlmap -u ..."]}} +]} diff --git a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py new file mode 100644 index 00000000..a7877c4f --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py @@ -0,0 +1,931 @@ +import asyncio +import copy +import json +import os +import shutil +import ssl +import subprocess +import sys +import threading +import time +from pathlib import Path + +import aiohttp +import pytest +import pytest_asyncio +from aiohttp import web +from pydantic import ValidationError + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from tool.darkmoon_pentest import ( # noqa: E402 + DarkmoonAPIError, + DarkmoonFinding, + DarkmoonPentestTool, + DarkmoonPentestToolConfig, + DarkmoonPentestToolInputSchema, + DarkmoonPentestToolOutputSchema, + DarkmoonRedirectError, + DarkmoonRunError, + DarkmoonSchemaError, + STATS_BUCKETS, +) + +FIXTURES = Path(__file__).parent / "fixtures" +RUN_ID = "run_20261004_101500_a1b2c3d4" +CAMPAIGN_ID = "camp_20261004_5f3a9c1e" +PASSWORD = "s3cret-Passw0rd" +RUN_INPUT = DarkmoonPentestToolInputSchema(action="run_pentest", target="target.example") + + +def fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +class FakeDarkmoon: + """In-process stand-in for the Dashboard API routes the tool uses. Bodies come from the contract fixtures.""" + + def __init__(self): + self.token = "jwt-123" + self.campaigns = fixture("campaigns.json") + self.vulns = fixture("vulnerabilities.json") + self.run_start = fixture("run_campaign.json") + self.log_replies = [(200, fixture("run_logs_completed.json"))] + self.logs_delay = 0.0 + self.system_info = None + self.run_body = None + self.requests = [] + + def _authorised(self, request): + return request.headers.get("Authorization") == f"Bearer {self.token}" + + async def login(self, request): + body = await request.json() + self.requests.append(("login", body)) + if body != {"username": "analyst", "password": PASSWORD}: + return web.json_response({"detail": "Invalid credentials"}, status=401) + return web.json_response(fixture("login.json") | {"token": self.token}) + + async def info(self, request): + if self.system_info is None: + return web.json_response({"detail": "Not Found"}, status=404) + return web.json_response(self.system_info) + + async def list_campaigns(self, request): + if not self._authorised(request): + return web.json_response({"detail": "Not authenticated"}, status=401) + self.requests.append(("campaigns", None)) + return web.json_response(self.campaigns) + + async def vulnerabilities(self, request): + if not self._authorised(request): + return web.json_response({"detail": "Not authenticated"}, status=401) + self.requests.append(("vulnerabilities", request.query["campaign_id"])) + return web.json_response(self.vulns) + + async def run_campaign(self, request): + if not self._authorised(request): + return web.json_response({"detail": "Not authenticated"}, status=401) + self.run_body = await request.json() + self.requests.append(("run", None)) + return web.json_response(self.run_start) + + async def run_logs(self, request): + self.requests.append(("logs", time.monotonic())) + await asyncio.sleep(self.logs_delay) + status, payload = self.log_replies[0] if len(self.log_replies) == 1 else self.log_replies.pop(0) + return web.json_response(payload, status=status) + + def app(self): + app = web.Application() + app.router.add_post("/api/v1/auth/login", self.login) + app.router.add_get("/api/v1/system/info", self.info) + app.router.add_get("/api/v1/campaigns", self.list_campaigns) + app.router.add_get("/api/v1/vulnerabilities", self.vulnerabilities) + app.router.add_post("/api/v1/run/campaign", self.run_campaign) + app.router.add_get("/api/v1/run/logs/{run_id}", self.run_logs) + return app + + def count(self, name): + return sum(1 for entry in self.requests if entry[0] == name) + + +async def start_server(fake, ssl_context=None): + runner = web.AppRunner(fake.app()) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_context) + await site.start() + port = site._server.sockets[0].getsockname()[1] + scheme = "https" if ssl_context else "http" + return runner, f"{scheme}://127.0.0.1:{port}/" + + +def make_tool(base_url, **overrides): + settings = {"base_url": base_url, "username": "analyst", "password": PASSWORD, "poll_interval": 0.01, "run_timeout": 5} + return DarkmoonPentestTool(config=DarkmoonPentestToolConfig(**(settings | overrides))) + + +@pytest_asyncio.fixture +async def darkmoon(): + fake = FakeDarkmoon() + runner, base_url = await start_server(fake) + yield fake, make_tool(base_url) + await runner.cleanup() + + +@pytest.fixture +def darkmoon_sync(): + """Serve the fake API from its own event loop thread so the blocking run() can be called.""" + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + runner, base_url = asyncio.run_coroutine_threadsafe(start_server(FakeDarkmoon()), loop).result(timeout=10) + yield make_tool(base_url) + asyncio.run_coroutine_threadsafe(runner.cleanup(), loop).result(timeout=10) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=10) + loop.close() + + +def logs_with(*events): + return (200, {"data": list(events)}) + + +####################### +# Schema and metadata # +####################### +def test_input_schema_requires_target_for_run(): + with pytest.raises(ValidationError): + DarkmoonPentestToolInputSchema(action="run_pentest") + with pytest.raises(ValidationError): + DarkmoonPentestToolInputSchema(action="run_pentest", target=" ") + + +def test_input_schema_requires_campaign_for_findings(): + with pytest.raises(ValidationError): + DarkmoonPentestToolInputSchema(action="get_findings") + assert DarkmoonPentestToolInputSchema(action="list_campaigns").target is None + + +@pytest.mark.parametrize("score", [-0.1, 10.1, 99]) +def test_finding_rejects_out_of_range_cvss(score): + with pytest.raises(ValidationError): + DarkmoonFinding(title="x", cvss_score=score) + + +@pytest.mark.parametrize("score", [0, 5.5, 10]) +def test_finding_accepts_cvss_range(score): + assert DarkmoonFinding(title="x", cvss_score=score).cvss_score == score + + +def test_tool_metadata(): + tool = DarkmoonPentestTool(config=DarkmoonPentestToolConfig(base_url="http://x")) + assert tool.input_schema is DarkmoonPentestToolInputSchema + assert tool.output_schema is DarkmoonPentestToolOutputSchema + assert "authorised" in tool.tool_description + + +def test_environment_fallback(monkeypatch): + monkeypatch.setenv("DARKMOON_BASE_URL", "http://dm.local:8000/") + monkeypatch.setenv("DARKMOON_USERNAME", "u") + monkeypatch.setenv("DARKMOON_PASSWORD", "p") + tool = DarkmoonPentestTool() + assert (tool.base_url, tool.username, tool.password) == ("http://dm.local:8000", "u", "p") + + +@pytest.mark.asyncio +async def test_missing_settings_raise(monkeypatch): + for name in ("DARKMOON_BASE_URL", "DARKMOON_USERNAME", "DARKMOON_PASSWORD"): + monkeypatch.delenv(name, raising=False) + tool = DarkmoonPentestTool() + with pytest.raises(ValueError, match="DARKMOON_BASE_URL"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + + +@pytest.mark.asyncio +async def test_base_url_must_be_http_or_https(): + tool = make_tool("file:///etc/passwd") + with pytest.raises(ValueError, match="http:// or https://"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + + +##################### +# Secret boundaries # +##################### +def test_password_is_not_in_repr_or_str(): + config = DarkmoonPentestToolConfig(base_url="http://x", username="analyst", password=PASSWORD) + tool = DarkmoonPentestTool(config=config) + for rendered in (repr(config), str(config), repr(tool), str(tool)): + assert PASSWORD not in rendered + assert config.password == PASSWORD + + +@pytest.mark.asyncio +async def test_bad_credentials_report_api_detail_without_leaking_password(darkmoon): + _, tool = darkmoon + tool.password = "wrong-password" + with pytest.raises(DarkmoonAPIError, match="401: Invalid credentials") as excinfo: + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert excinfo.value.status == 401 + assert "wrong-password" not in str(excinfo.value) and PASSWORD not in str(excinfo.value) + + +##################### +# Read-only actions # +##################### +@pytest.mark.asyncio +async def test_list_campaigns(darkmoon): + _, tool = darkmoon + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert [c.id for c in output.campaigns] == ["camp_20260101_01d5e551", CAMPAIGN_ID] + assert output.campaigns[1].date == "2026-10-04T10:15:30+00:00" + + +@pytest.mark.asyncio +async def test_get_findings_keeps_known_fields_only(darkmoon): + fake, tool = darkmoon + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + assert (output.total, output.stats["by_severity"]) == (1, {"high": 1}) + finding = output.findings[0] + assert isinstance(finding, DarkmoonFinding) + assert (finding.title, finding.severity, finding.cvss_score) == ("SQL injection in /api/items", "high", 8.1) + assert ("vulnerabilities", CAMPAIGN_ID) in fake.requests + + +@pytest.mark.asyncio +async def test_get_findings_drops_unknown_fields(darkmoon): + _, tool = darkmoon + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + assert "evidence" not in output.findings[0].model_dump() + + +################################ +# API shape validation (schema) # +################################ +def bad_campaign_bodies(): + return [[], {"data": "nope"}, {"total": 0}, {"data": [{"date": "2026"}]}, {"data": ["x"]}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", bad_campaign_bodies()) +async def test_malformed_campaign_list_is_an_error_not_an_empty_list(darkmoon, body): + fake, tool = darkmoon + fake.campaigns = body + with pytest.raises(DarkmoonSchemaError): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + + +def bad_vulnerability_bodies(): + good = fixture("vulnerabilities.json") + untitled = copy.deepcopy(good) | {"data": [{"severity": "low"}]} + cvss99 = copy.deepcopy(good) + cvss99["data"][0]["cvss_score"] = 99 + return [[], {"data": None}, {"total": 0}, untitled, cvss99, good | {"total": "1"}, good | {"stats": []}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", bad_vulnerability_bodies()) +async def test_malformed_findings_are_an_error_not_zero_findings(darkmoon, body): + fake, tool = darkmoon + fake.vulns = body + with pytest.raises(DarkmoonSchemaError): + await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + + +@pytest.mark.asyncio +async def test_login_without_token_is_a_schema_error(darkmoon, monkeypatch): + _, tool = darkmoon + original = tool._request + + async def no_token(session, method, path, *args, **kwargs): + if path.endswith("/auth/login"): + return {"user": "analyst"} # login succeeded but returned no token + return await original(session, method, path, *args, **kwargs) + + monkeypatch.setattr(tool, "_request", no_token) + with pytest.raises(DarkmoonSchemaError, match="token"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [[], {}, {"pid": 1}, {"run_id": ""}, {"run_id": 7}]) +async def test_run_start_without_run_id_is_a_schema_error(darkmoon, body): + fake, tool = darkmoon + fake.run_start = body + with pytest.raises(DarkmoonSchemaError, match="run start"): + await tool.run_async(RUN_INPUT) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [[], {"data": "x"}, {"total": 0}]) +async def test_malformed_run_log_is_a_schema_error(darkmoon, body): + fake, tool = darkmoon + fake.log_replies = [(200, body)] + with pytest.raises(DarkmoonSchemaError): + await tool.run_async(RUN_INPUT) + + +################### +# API version pin # +################### +@pytest.mark.asyncio +async def test_supported_api_version_is_accepted(darkmoon): + fake, tool = darkmoon + fake.system_info = fixture("system_info.json") + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert len(output.campaigns) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("version", ["2.0.0", "v3", "0.9.1"]) +async def test_unsupported_api_major_is_refused(darkmoon, version): + fake, tool = darkmoon + fake.system_info = fixture("system_info.json") | {"api_version": version} + with pytest.raises(DarkmoonSchemaError, match="Unsupported"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert fake.count("login") == 0 + + +@pytest.mark.asyncio +async def test_system_info_server_error_is_not_swallowed(darkmoon, monkeypatch): + _, tool = darkmoon + original = tool._request + + async def failing(session, method, path, *args, **kwargs): + if path.endswith("/system/info"): + raise DarkmoonAPIError(500, "boom") + return await original(session, method, path, *args, **kwargs) + + monkeypatch.setattr(tool, "_request", failing) + with pytest.raises(DarkmoonAPIError) as excinfo: + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert excinfo.value.status == 500 + + +################ +# run_pentest # +################ +@pytest.mark.asyncio +async def test_run_pentest_sends_body_and_reports_status_without_attributing_findings(darkmoon): + fake, tool = darkmoon + params = DarkmoonPentestToolInputSchema( + action="run_pentest", target="target.example", program="Q3 scope", focus=["auth", " injection "], min_severity="medium" + ) + output = await tool.run_async(params) + assert fake.run_body == { + "target": "target.example", + "program": "Q3 scope", + "focus": ["auth", "injection"], + "severity": "medium", + } + # The run is reported as completed, but NO campaign/findings are attributed to it. + assert (output.run_id, output.timed_out, output.run_status) == (RUN_ID, False, "completed") + assert output.campaign_id is None and output.findings == [] and output.total == 0 + # The tool never guesses a campaign: it fetches neither the campaign list nor any findings. + assert fake.count("campaigns") == 0 and fake.count("vulnerabilities") == 0 + + +@pytest.mark.asyncio +async def test_completed_run_does_not_attribute_a_window_matching_old_campaign(darkmoon): + """Round-4 counterexample: run session ``ses_2a9e5f3a9c1eAbCdEf0123`` must NOT be paired with the + sole old campaign ``camp_20200101_5f3a9c1e`` just because ``5f3a9c1e`` is an 8-char window of the + session. Suffix/window matching does not establish ownership, so the tool returns status only.""" + fake, tool = darkmoon + # The completed run log's first sessionID is exactly ses_2a9e5f3a9c1eAbCdEf0123 (see fixture). + fake.campaigns["data"] = [ + {"id": "camp_20200101_5f3a9c1e", "session_id": "5f3a9c1e", "is_subagent": False, "date": "2020-01-01T00:00:00+00:00"} + ] + output = await tool.run_async(RUN_INPUT) + assert output.run_status == "completed" + assert output.campaign_id is None and output.findings == [] + assert ("vulnerabilities", "camp_20200101_5f3a9c1e") not in fake.requests + assert fake.count("vulnerabilities") == 0 and fake.count("campaigns") == 0 + + +@pytest.mark.asyncio +async def test_explicit_campaign_selection_still_returns_findings(darkmoon): + """The list/get path is preserved: findings are read by explicit campaign id, not auto-attribution.""" + fake, tool = darkmoon + listed = await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + chosen = listed.campaigns[1].id + report = await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=chosen)) + assert report.campaign_id == chosen + assert report.total == 1 and report.findings[0].category == "sql_injection" + assert ("vulnerabilities", chosen) in fake.requests + + +@pytest.mark.asyncio +async def test_run_without_waiting_returns_run_id_only(darkmoon): + fake, tool = darkmoon + params = DarkmoonPentestToolInputSchema(action="run_pentest", target="target.example", wait_for_completion=False) + output = await tool.run_async(params) + assert output.run_id == RUN_ID and output.run_status == "started" + assert output.findings == [] and output.campaign_id is None + assert fake.count("logs") == 0 + + +@pytest.mark.asyncio +async def test_run_waits_through_a_missing_log_then_completes(darkmoon): + fake, tool = darkmoon + started = {"type": "run_started", "run_id": RUN_ID} + fake.log_replies = [(404, {"detail": "Run log not found"}), logs_with(started), (200, fixture("run_logs_completed.json"))] + output = await tool.run_async(RUN_INPUT) + assert output.run_status == "completed" and output.campaign_id is None + assert fake.count("logs") == 3 + + +################################ +# Failed runs are failures # +################################ +@pytest.mark.asyncio +@pytest.mark.parametrize( + "fixture_name, expected", + [("run_logs_error.json", "provider rate limit exceeded"), ("run_logs_aborted.json", "aborted")], +) +async def test_failed_run_raises_instead_of_returning_zero_findings(darkmoon, fixture_name, expected): + fake, tool = darkmoon + fake.log_replies = [(200, fixture(fixture_name))] + with pytest.raises(DarkmoonRunError, match=expected) as excinfo: + await tool.run_async(RUN_INPUT) + assert RUN_ID in str(excinfo.value) + assert fake.count("vulnerabilities") == 0 + + +@pytest.mark.asyncio +async def test_nonzero_exit_code_is_a_failure(darkmoon): + fake, tool = darkmoon + fake.log_replies = [logs_with({"type": "run_completed", "exit_code": 2})] + with pytest.raises(DarkmoonRunError, match="code 2"): + await tool.run_async(RUN_INPUT) + + +@pytest.mark.asyncio +async def test_run_error_detail_is_truncated(darkmoon): + fake, tool = darkmoon + fake.log_replies = [logs_with({"type": "run_error", "error": "x" * 5000})] + with pytest.raises(DarkmoonRunError) as excinfo: + await tool.run_async(RUN_INPUT) + assert len(str(excinfo.value)) < 500 + + +########################## +# Deadline and HTTP status # +########################## +@pytest.mark.asyncio +async def test_poll_sleep_is_capped_by_the_remaining_deadline(darkmoon): + fake, tool = darkmoon + fake.log_replies = [logs_with({"type": "run_started"})] + tool.run_timeout, tool.poll_interval = 1.0, 100.0 + started = time.monotonic() + output = await tool.run_async(RUN_INPUT) + assert output.timed_out is True and output.run_id == RUN_ID + assert time.monotonic() - started < 5 + + +@pytest.mark.asyncio +async def test_request_is_capped_by_the_remaining_deadline(darkmoon): + fake, tool = darkmoon + fake.logs_delay = 3.0 + tool.run_timeout, tool.timeout = 0.5, 60.0 + started = time.monotonic() + output = await tool.run_async(RUN_INPUT) + assert output.timed_out is True + assert time.monotonic() - started < 2.5 + + +@pytest.mark.asyncio +async def test_slow_request_far_from_the_deadline_is_an_error(darkmoon): + fake, tool = darkmoon + fake.logs_delay = 3.0 + tool.run_timeout, tool.timeout = 600.0, 0.2 + with pytest.raises(asyncio.TimeoutError): + await tool.run_async(RUN_INPUT) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status, detail", [(500, "upstream said 404 earlier"), (500, "Run 404 failed"), (403, "forbidden")]) +async def test_http_errors_are_not_confused_with_a_missing_log(darkmoon, status, detail): + fake, tool = darkmoon + fake.log_replies = [(status, {"detail": detail})] + with pytest.raises(DarkmoonAPIError) as excinfo: + await tool.run_async(RUN_INPUT) + assert excinfo.value.status == status + assert fake.count("logs") == 1 + + +@pytest.mark.asyncio +async def test_cancellation_propagates_and_stops_polling(darkmoon): + fake, tool = darkmoon + fake.log_replies = [logs_with({"type": "run_started"})] + tool.run_timeout, tool.poll_interval = 60.0, 0.05 + task = asyncio.ensure_future(tool.run_async(RUN_INPUT)) + while fake.count("logs") < 2: + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + seen = fake.count("logs") + await asyncio.sleep(0.3) + assert fake.count("logs") == seen + + +@pytest.mark.asyncio +async def test_non_json_error_body_reports_the_http_reason(): + class Resp: + async def json(self, content_type=None): + raise ValueError("not json") + + from tool.darkmoon_pentest import _error_message, _read_json + + payload = await _read_json(Resp()) + assert payload is None + assert _error_message(payload, "Bad Gateway") == "Bad Gateway" + + +####### +# TLS # +####### +@pytest.mark.skipif(shutil.which("openssl") is None, reason="openssl CLI needed to mint a throwaway certificate") +@pytest.mark.asyncio +async def test_self_signed_certificate_is_rejected(tmp_path): + cert, key = tmp_path / "cert.pem", tmp_path / "key.pem" + subprocess.run( + ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=127.0.0.1"] + + ["-addext", "subjectAltName=IP:127.0.0.1", "-keyout", str(key), "-out", str(cert)], + check=True, + capture_output=True, + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + fake = FakeDarkmoon() + runner, base_url = await start_server(fake, context) + try: + with pytest.raises(aiohttp.ClientConnectorCertificateError): + await make_tool(base_url).run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert fake.requests == [] + finally: + await runner.cleanup() + + +def test_run_sync_wrapper(darkmoon_sync): + output = darkmoon_sync.run(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert output.campaigns[0].id == "camp_20260101_01d5e551" + + +async def start_app(app): + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, f"http://127.0.0.1:{port}/" + + +############################################# +# Cross-origin credential redirects (ask 1) # +############################################# +@pytest.mark.asyncio +async def test_cross_origin_login_redirect_is_refused_and_credentials_not_forwarded(): + """A 307 that would replay the login POST (with its body) to another origin must be refused.""" + attacker = FakeDarkmoon() + attacker_runner, attacker_url = await start_server(attacker) + + async def redirect_login(request): + # 307 preserves method + body: this is exactly the credential-forwarding risk. + return web.Response(status=307, headers={"Location": f"{attacker_url}api/v1/auth/login"}) + + async def no_info(request): + return web.json_response({"detail": "Not Found"}, status=404) + + app = web.Application() + app.router.add_post("/api/v1/auth/login", redirect_login) + app.router.add_get("/api/v1/system/info", no_info) + primary_runner, primary_url = await start_app(app) + try: + tool = make_tool(primary_url) + with pytest.raises(DarkmoonRedirectError, match="different origin"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert attacker.count("login") == 0 # the credentials never reached the other origin + finally: + await primary_runner.cleanup() + await attacker_runner.cleanup() + + +def test_same_origin_redirect_target_is_followed(): + tool = make_tool("http://127.0.0.1:8000/") + resolved = tool._same_origin_target("http://127.0.0.1:8000/api/v1/auth/login", "/api/v1/auth/login/") + assert resolved == "http://127.0.0.1:8000/api/v1/auth/login/" + + +@pytest.mark.asyncio +async def test_same_origin_redirect_is_followed_end_to_end(): + async def no_info(request): + return web.json_response({"detail": "Not Found"}, status=404) + + async def login_redirect(request): + return web.Response(status=307, headers={"Location": "/api/v1/auth/login2"}) # same origin + + async def login2(request): + return web.json_response({"token": "jwt-123"}) + + async def campaigns(request): + return web.json_response(fixture("campaigns.json")) + + app = web.Application() + app.router.add_get("/api/v1/system/info", no_info) + app.router.add_post("/api/v1/auth/login", login_redirect) + app.router.add_post("/api/v1/auth/login2", login2) + app.router.add_get("/api/v1/campaigns", campaigns) + runner, base = await start_app(app) + try: + output = await make_tool(base).run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert len(output.campaigns) == 2 + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_redirect_without_location_is_refused(): + async def no_info(request): + return web.json_response({"detail": "Not Found"}, status=404) + + async def login_no_location(request): + return web.Response(status=307) # no Location header + + app = web.Application() + app.router.add_get("/api/v1/system/info", no_info) + app.router.add_post("/api/v1/auth/login", login_no_location) + runner, base = await start_app(app) + try: + with pytest.raises(DarkmoonRedirectError, match="without a Location"): + await make_tool(base).run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_redirect_loop_is_bounded(): + async def no_info(request): + return web.json_response({"detail": "Not Found"}, status=404) + + async def login_loop(request): + return web.Response(status=307, headers={"Location": "/api/v1/auth/login"}) # same-origin self-loop + + app = web.Application() + app.router.add_get("/api/v1/system/info", no_info) + app.router.add_post("/api/v1/auth/login", login_loop) + runner, base = await start_app(app) + try: + with pytest.raises(DarkmoonRedirectError, match="exceeded"): + await make_tool(base).run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + finally: + await runner.cleanup() + + +@pytest.mark.parametrize("location", ["http://evil.example/steal", "https://127.0.0.1:8000/x", "http://127.0.0.1:9999/x"]) +def test_cross_origin_redirect_target_is_refused(location): + tool = make_tool("http://127.0.0.1:8000/") + with pytest.raises(DarkmoonRedirectError, match="different origin"): + tool._same_origin_target("http://127.0.0.1:8000/api/v1/auth/login", location) + + +########################################## +# Secrets in exception messages (ask 2) # +########################################## +@pytest.mark.asyncio +async def test_api_error_text_is_scrubbed_of_password_and_token(): + token = "jwt-SECRET-123" + + async def login(request): + return web.json_response({"token": token}) + + async def info(request): + return web.json_response({"detail": "Not Found"}, status=404) + + async def campaigns(request): + # Hostile/buggy API echoing the caller's secrets back in an error detail. + return web.json_response({"detail": f"password={PASSWORD} token={token}"}, status=400) + + app = web.Application() + app.router.add_post("/api/v1/auth/login", login) + app.router.add_get("/api/v1/system/info", info) + app.router.add_get("/api/v1/campaigns", campaigns) + runner, base = await start_app(app) + try: + tool = make_tool(base) + with pytest.raises(DarkmoonAPIError) as excinfo: + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + text = str(excinfo.value) + assert PASSWORD not in text and token not in text + assert "***" in text + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_run_error_text_is_scrubbed_of_token(darkmoon): + fake, tool = darkmoon + fake.log_replies = [logs_with({"type": "run_error", "error": f"crashed with leaked token {fake.token}"})] + with pytest.raises(DarkmoonRunError) as excinfo: + await tool.run_async(RUN_INPUT) + assert fake.token not in str(excinfo.value) + assert "***" in str(excinfo.value) + + +################################################ +# Malformed findings counts / stats (ask 3) # +################################################ +def _mutate_total_negative(body): + body["total"] = -9 + + +def _mutate_total_below_count(body): + body["total"] = 0 # one finding is returned, so total 0 is impossible + + +def _mutate_stats_not_object(body): + body["stats"]["by_severity"] = "broken" + + +def _mutate_stats_negative_count(body): + body["stats"]["by_severity"]["high"] = -1 + + +def _mutate_stats_non_int_count(body): + body["stats"]["by_severity"]["high"] = "lots" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mutate", + [ + _mutate_total_negative, + _mutate_total_below_count, + _mutate_stats_not_object, + _mutate_stats_negative_count, + _mutate_stats_non_int_count, + ], +) +async def test_malformed_counts_or_stats_are_rejected(darkmoon, mutate): + fake, tool = darkmoon + body = copy.deepcopy(fixture("vulnerabilities.json")) + mutate(body) + fake.vulns = body + with pytest.raises(DarkmoonSchemaError): + await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + + +@pytest.mark.asyncio +async def test_well_formed_counts_and_stats_are_accepted(darkmoon): + _, tool = darkmoon + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + assert output.total == 1 + assert output.stats["by_severity"] == {"high": 1} + + +########################################## +# Present-but-invalid version (ask 4) # +########################################## +@pytest.mark.asyncio +@pytest.mark.parametrize( + "info", + [ + {}, # no api_version, no contract_version + {"api_version": "banana", "contract_version": "1"}, # garbage version + {"api_version": "1.0.0"}, # missing contract_version + {"api_version": "1.0.0", "contract_version": "2"}, # incompatible contract (str) + {"api_version": "1.0.0", "contract_version": 2}, # incompatible contract (int) + [], # malformed HTTP 200 body (not an object) + ], +) +async def test_present_but_invalid_version_contract_is_refused(darkmoon, info): + fake, tool = darkmoon + fake.system_info = info + with pytest.raises(DarkmoonSchemaError): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert fake.count("login") == 0 # fail closed before authenticating + + +@pytest.mark.asyncio +async def test_supported_contract_version_is_accepted(darkmoon): + fake, tool = darkmoon + fake.system_info = fixture("system_info.json") + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert len(output.campaigns) == 2 + + +########################################## +# Null stats bucket (ask 4) # +########################################## +@pytest.mark.asyncio +@pytest.mark.parametrize("bucket", STATS_BUCKETS) +async def test_null_stats_bucket_is_rejected(darkmoon, bucket): + """An explicitly null bucket must not skip validation: a present bucket has to be a real object.""" + fake, tool = darkmoon + body = copy.deepcopy(fixture("vulnerabilities.json")) + body["stats"][bucket] = None + fake.vulns = body + with pytest.raises(DarkmoonSchemaError, match=f"stats.{bucket}"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + + +@pytest.mark.asyncio +async def test_absent_stats_bucket_is_still_optional(darkmoon): + """A bucket that is simply not present stays optional; only a present-but-bad bucket is rejected.""" + fake, tool = darkmoon + body = copy.deepcopy(fixture("vulnerabilities.json")) + del body["stats"]["by_category"] + fake.vulns = body + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + assert output.total == 1 + + +###################################################### +# Version / bucket-name secret scrubbing (ask 3) # +###################################################### +@pytest.mark.asyncio +async def test_version_value_is_scrubbed_in_error(darkmoon): + """A hostile /system/info whose api_version echoes the password must not leak it in the error.""" + fake, tool = darkmoon + fake.system_info = {"api_version": f"evil-{PASSWORD}", "contract_version": "1"} + with pytest.raises(DarkmoonSchemaError) as excinfo: + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert PASSWORD not in str(excinfo.value) and "***" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_stats_bucket_name_is_scrubbed_in_error(darkmoon): + """A stats bucket key that embeds the password must be scrubbed when it surfaces in a schema error.""" + fake, tool = darkmoon + body = copy.deepcopy(fixture("vulnerabilities.json")) + body["stats"]["by_severity"] = {f"high-{PASSWORD}": -1} # bad count forces the bucket name into the message + fake.vulns = body + with pytest.raises(DarkmoonSchemaError) as excinfo: + await tool.run_async(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=CAMPAIGN_ID)) + assert PASSWORD not in str(excinfo.value) and "***" in str(excinfo.value) + + +##################################################### +# Redirect hops share one time budget (ask 2) # +##################################################### +@pytest.mark.asyncio +async def test_redirect_hops_share_one_whole_request_budget(): + """Two same-origin hops that each take ~0.4s must not both get the full budget: the second hop + only gets the time left, so a 0.5s budget is exceeded and the request times out at ~0.5s, not ~0.8s.""" + + async def no_info(request): + return web.json_response({"detail": "Not Found"}, status=404) + + async def login_hop1(request): + await asyncio.sleep(0.4) + return web.Response(status=307, headers={"Location": "/api/v1/auth/login2"}) # same origin + + async def login_hop2(request): + await asyncio.sleep(0.4) + return web.json_response({"token": "jwt-123"}) + + app = web.Application() + app.router.add_get("/api/v1/system/info", no_info) + app.router.add_post("/api/v1/auth/login", login_hop1) + app.router.add_post("/api/v1/auth/login2", login_hop2) + runner, base = await start_app(app) + try: + tool = make_tool(base) + tool.timeout = 0.5 # whole-request budget for the (redirecting) login call + started = time.monotonic() + with pytest.raises(asyncio.TimeoutError): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert time.monotonic() - started < 0.75 # proves the two hops did not each get a fresh 0.5s + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_terminal_result_past_the_deadline_is_rejected(darkmoon): + """Even when a hop returns a final body, the deadline is rechecked before the body is accepted.""" + _, tool = darkmoon + + async def slow_terminal(session, method, url, body, headers, limit): + await asyncio.sleep(0.05) + return None, {"ok": True} + + tool._send_once = slow_terminal + with pytest.raises(asyncio.TimeoutError): + await tool._request(None, "GET", "/api/v1/campaigns", timeout=0.01) + + +@pytest.mark.asyncio +async def test_redirect_hop_that_consumes_the_budget_stops_before_the_next_hop(darkmoon): + """Once the budget is spent, no further hop is attempted (the remaining-time guard fires).""" + _, tool = darkmoon + calls = [] + + async def one_slow_redirect(session, method, url, body, headers, limit): + calls.append(url) + await asyncio.sleep(0.05) + return f"{tool.base_url}/api/v1/auth/login2", None # same-origin redirect target + + tool._send_once = one_slow_redirect + with pytest.raises(asyncio.TimeoutError): + await tool._request(None, "GET", "/api/v1/auth/login", timeout=0.01) + assert len(calls) == 1 # the second hop was never sent diff --git a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py new file mode 100644 index 00000000..c16936af --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py @@ -0,0 +1,584 @@ +import asyncio +import os +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple +from urllib.parse import quote, urljoin, urlsplit + +import aiohttp +from pydantic import Field, ValidationError, model_validator + +from atomic_agents import BaseIOSchema, BaseTool, BaseToolConfig + + +TERMINAL_EVENTS = frozenset({"run_completed", "run_error"}) +SUPPORTED_API_MAJOR = 1 # Darkmoon Pro Dashboard API major version this tool is written and tested against. +SUPPORTED_CONTRACT_VERSION = "1" # GET /api/v1/system/info `contract_version` this tool understands. +MAX_ERROR_CHARS = 300 +REQUIRED_FIELD = {"run_pentest": "target", "get_findings": "campaign_id"} +REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +MAX_REDIRECTS = 5 +STATS_BUCKETS = ("by_severity", "by_category", "by_status") + +# Run <-> campaign attribution: NOT auto-attributed. +# +# The public contract exposes no verifiable run->campaign key. A campaign record +# (mcp/api/live_push.py: the dict built at `campaign = {...}` carries only +# id/project_id/target_id/session_id/is_subagent/date/status/stats/...) has no +# run_id, no owner and no full session id, and the Pro run-start response carries no +# campaign id. The only linkage the server itself performs is a best-effort +# reconciliation sweep that matches a campaign's 8-char `session_id` as a substring +# window of a run's parent session id (mcp/src/server.py `SESSION_ID = uuid4().hex[:8]`; +# the 8-char fragment scan in live_push.py) -- a heuristic, not authoritative ownership. +# +# So this tool does NOT attribute findings to a run it starts. `run_pentest` returns +# the run's STATUS; findings are read explicitly with `list_campaigns` + `get_findings` +# for a campaign id the caller chooses. See the README "Why findings are not +# auto-attributed to a run" section. + + +def _is_blank(value: Optional[str]) -> bool: + return not (value or "").strip() + + +class DarkmoonAPIError(RuntimeError): + """The Dashboard API answered with an HTTP error status.""" + + def __init__(self, status: int, message: str): + super().__init__(f"Darkmoon API error {status}: {message}") + self.status = status + + +class DarkmoonSchemaError(RuntimeError): + """The Dashboard API answered, but not with the response shape this tool supports.""" + + +class DarkmoonRunError(RuntimeError): + """The Darkmoon run itself failed or was aborted. This is a failed assessment, not an empty result.""" + + +class DarkmoonRedirectError(RuntimeError): + """The API tried to redirect a request to a different origin; it is refused so credentials never leave base_url.""" + + +################ +# INPUT SCHEMA # +################ +class DarkmoonPentestToolInputSchema(BaseIOSchema): + """ + Work with a self-hosted Darkmoon autonomous AI pentest platform through its Dashboard API. + Use action 'run_pentest' to launch an assessment against one target the user is authorised to test + (optionally waiting for the run to finish and reporting its status), 'list_campaigns' to list existing + campaigns, and 'get_findings' to read the vulnerabilities and severity statistics of one campaign. + 'run_pentest' does not auto-attribute findings to the run (the API exposes no verifiable run-to-campaign + key); read findings afterwards with 'list_campaigns' then 'get_findings' for the campaign id you choose. + Findings may contain false positives and need human review. Requires a Darkmoon instance with the web + dashboard (a Pro feature). + """ + + action: Literal["run_pentest", "list_campaigns", "get_findings"] = Field( + ..., + description="'run_pentest' starts a pentest, 'list_campaigns' lists campaigns, 'get_findings' reads one campaign.", + ) + target: Optional[str] = Field( + default=None, + description="Host, URL or scope to assess. Required for 'run_pentest'. Only targets you are authorised to test.", + ) + campaign_id: Optional[str] = Field( + default=None, description="Darkmoon campaign id, e.g. camp_20260922_abc123. Required for 'get_findings'." + ) + wait_for_completion: bool = Field( + default=True, + description=( + "For 'run_pentest': wait for the run to finish and return its status (not its findings, which are not " + "auto-attributed; read them with 'get_findings' and an explicit campaign_id). If false, return the run id only." + ), + ) + program: Optional[str] = Field( + default=None, description="For 'run_pentest': optional program name or rules of engagement." + ) + focus: Optional[List[str]] = Field( + default=None, description="For 'run_pentest': optional focus areas, e.g. ['auth', 'injection']." + ) + min_severity: Optional[Literal["info", "low", "medium", "high", "critical"]] = Field( + default=None, description="For 'run_pentest': optional minimum severity to report." + ) + + @model_validator(mode="after") + def _check_required_fields(self) -> "DarkmoonPentestToolInputSchema": + field = REQUIRED_FIELD.get(self.action) + if field and _is_blank(getattr(self, field)): + raise ValueError(f"'{field}' is required for action '{self.action}'.") + return self + + +#################### +# OUTPUT SCHEMA(S) # +#################### +class DarkmoonCampaign(BaseIOSchema): + """A Darkmoon campaign as listed by the dashboard.""" + + id: str = Field(..., description="Campaign id.") + date: Optional[str] = Field(None, description="Campaign date as reported by the dashboard.") + + +class DarkmoonFinding(BaseIOSchema): + """A vulnerability recorded by Darkmoon. Findings may be false positives and need human review.""" + + title: str = Field(..., description="Finding title.") + severity: Optional[str] = Field(None, description="critical, high, medium, low or info.") + cvss_score: Optional[float] = Field(None, ge=0, le=10, description="CVSS score between 0 and 10.") + category: Optional[str] = Field(None, description="Vulnerability category, e.g. sql_injection or ssrf.") + status: Optional[str] = Field(None, description="exploited, confirmed or unconfirmed.") + endpoint: Optional[str] = Field(None, description="Affected endpoint.") + cve: Optional[str] = Field(None, description="Related CVE identifier, if any.") + description: Optional[str] = Field(None, description="Finding description.") + remediation: Optional[str] = Field(None, description="Suggested remediation, if provided.") + + +class DarkmoonPentestToolOutputSchema(BaseIOSchema): + """Result of a Darkmoon dashboard call: a started run, a campaign list, or the findings of one campaign.""" + + action: str = Field(..., description="The action that produced this result.") + run_id: Optional[str] = Field(None, description="Run id of the pentest that was started.") + run_status: Optional[str] = Field( + None, + description="For 'run_pentest': 'started' (not waited on), 'completed' (finished cleanly) or 'timed_out'.", + ) + campaign_id: Optional[str] = Field(None, description="Campaign the findings belong to (only set by 'get_findings').") + timed_out: bool = Field(False, description="True if the run was still going when the wait timeout elapsed.") + campaigns: List[DarkmoonCampaign] = Field(default_factory=list, description="Campaigns visible to the dashboard user.") + total: int = Field(0, description="Total number of findings in the campaign.") + stats: Dict[str, Any] = Field(default_factory=dict, description="Severity statistics reported by the dashboard.") + findings: List[DarkmoonFinding] = Field(default_factory=list, description="Findings of the campaign.") + + +################# +# CONFIGURATION # +################# +class DarkmoonPentestToolConfig(BaseToolConfig): + """Configuration for the DarkmoonPentestTool.""" + + base_url: str = Field(default="", description="Darkmoon Dashboard API base URL. Falls back to DARKMOON_BASE_URL.") + username: str = Field(default="", description="Dashboard username. Falls back to DARKMOON_USERNAME.") + password: str = Field( + default="", repr=False, description="Dashboard password. Falls back to DARKMOON_PASSWORD. Never shown in repr." + ) + timeout: float = Field(default=60.0, ge=1.0, le=600.0, description="HTTP request timeout in seconds.") + run_timeout: float = Field(default=1800.0, ge=1.0, description="Maximum seconds to wait for a pentest run to finish.") + poll_interval: float = Field(default=5.0, gt=0.0, description="Seconds between run status checks.") + + +########### +# HELPERS # +########### +async def _read_json(resp: aiohttp.ClientResponse) -> Any: + try: + return await resp.json(content_type=None) + except ValueError: + return None + + +def _error_message(payload: Any, reason: Optional[str]) -> str: + detail = payload.get("detail") if isinstance(payload, dict) else None + return detail if isinstance(detail, str) and detail else str(reason) + + +def _clean_focus(focus: Optional[List[str]]) -> List[str]: + return [f.strip() for f in focus or [] if f.strip()] + + +def _require_dict(payload: Any, what: str) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise DarkmoonSchemaError(f"Unexpected Darkmoon response for {what}: expected a JSON object.") + return payload + + +def _require_list(payload: Dict[str, Any], key: str, what: str) -> List[Any]: + value = payload.get(key) + if not isinstance(value, list): + raise DarkmoonSchemaError(f"Unexpected Darkmoon response for {what}: '{key}' must be a list.") + return value + + +def _require_str(payload: Dict[str, Any], key: str, what: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise DarkmoonSchemaError(f"Unexpected Darkmoon response for {what}: '{key}' must be a non-empty string.") + return value + + +def _parse_campaigns(payload: Any) -> List[Dict[str, Any]]: + items = _require_list(_require_dict(payload, "campaigns"), "data", "campaigns") + campaigns = [_require_dict(item, "a campaign") for item in items] + for campaign in campaigns: + _require_str(campaign, "id", "a campaign") + return campaigns + + +def _parse_finding(item: Any) -> DarkmoonFinding: + raw = _require_dict(item, "a finding") + _require_str(raw, "title", "a finding") + try: + return DarkmoonFinding(**{k: v for k, v in raw.items() if k in DarkmoonFinding.model_fields}) + except ValidationError as exc: + raise DarkmoonSchemaError(f"Invalid finding in Darkmoon response: {exc.error_count()} field error(s).") from exc + + +def _require_count(value: Any, what: str) -> int: + """A count must be a real, non-negative integer (bools and negatives are rejected).""" + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise DarkmoonSchemaError(f"Unexpected Darkmoon response for {what}: expected a non-negative integer.") + return value + + +def _require_stat_bucket(key: str, bucket: Any, scrub: Callable[[str], str]) -> None: + if not isinstance(bucket, dict): + raise DarkmoonSchemaError(f"Unexpected Darkmoon response for vulnerabilities: 'stats.{key}' must be an object.") + for name, count in bucket.items(): + # The bucket name comes straight from the API body and could echo a secret; scrub it. + _require_count(count, scrub(f"stats.{key}.{name}")) + + +def _require_stats(stats: Any, scrub: Callable[[str], str]) -> Dict[str, Any]: + if not isinstance(stats, dict): + raise DarkmoonSchemaError("Unexpected Darkmoon response for vulnerabilities: 'stats' must be an object.") + for key in STATS_BUCKETS: + if key in stats: # a present bucket (even an explicit null) must be a valid object + _require_stat_bucket(key, stats[key], scrub) + return stats + + +def _parse_findings(payload: Any, scrub: Callable[[str], str]) -> Dict[str, Any]: + body = _require_dict(payload, "vulnerabilities") + findings = [_parse_finding(item) for item in _require_list(body, "data", "vulnerabilities")] + total = _require_count(body.get("total", len(findings)), "vulnerabilities 'total'") + if total < len(findings): + raise DarkmoonSchemaError("Unexpected Darkmoon response for vulnerabilities: 'total' is below the returned count.") + stats = _require_stats(body.get("stats", {}), scrub) + return {"total": total, "stats": stats, "findings": findings} + + +def _failure_of(event: Dict[str, Any]) -> Optional[str]: + """Why a terminal event means the run failed, or None for a clean completion.""" + if event.get("type") == "run_error": + return str(event.get("error") or "no error detail")[:MAX_ERROR_CHARS] + if event.get("aborted"): + return "the run was aborted before it finished" + exit_code = event.get("exit_code") + return f"opencode exited with code {exit_code}" if exit_code not in (0, None) else None + + +def _terminal_event(events: Optional[List[Any]]) -> Optional[Dict[str, Any]]: + return next((e for e in events or [] if isinstance(e, dict) and e.get("type") in TERMINAL_EVENTS), None) + + +def _major_of(version: Any) -> Optional[int]: + major = str(version if version is not None else "").split(".")[0].strip().lstrip("vV") + return int(major) if major.isdigit() else None + + +def _check_api_version(info: Any, scrub: Callable[[str], str]) -> None: + """Reject a present-but-invalid or incompatible version contract. + + A present /system/info (HTTP 200) must be a JSON object carrying a parseable + api_version whose major equals SUPPORTED_API_MAJOR and a contract_version equal + to SUPPORTED_CONTRACT_VERSION. Garbage versions, a missing field, or an + incompatible major/contract all fail closed. The 'unversioned, accept' fallback + is only for an *absent* route (HTTP 404), handled by the caller. The version + values are interpolated from the API body, so each message is scrubbed of secrets. + """ + data = _require_dict(info, "system info") + if _major_of(data.get("api_version")) != SUPPORTED_API_MAJOR: + raise DarkmoonSchemaError( + scrub( + f"Unsupported or missing Darkmoon Dashboard API version {data.get('api_version')!r}; " + f"this tool supports major {SUPPORTED_API_MAJOR}." + ) + ) + if str(data.get("contract_version", "")).strip() != SUPPORTED_CONTRACT_VERSION: + raise DarkmoonSchemaError( + scrub( + f"Unsupported or missing Darkmoon Dashboard API contract_version {data.get('contract_version')!r}; " + f"this tool supports contract {SUPPORTED_CONTRACT_VERSION}." + ) + ) + + +def _origin(url: str) -> Tuple[str, str, Optional[int]]: + """Scheme, host and port that together identify an origin for same-origin comparison.""" + parts = urlsplit(url) + return (parts.scheme, parts.hostname or "", parts.port) + + +##################### +# MAIN TOOL & LOGIC # +##################### +class DarkmoonPentestTool(BaseTool[DarkmoonPentestToolInputSchema, DarkmoonPentestToolOutputSchema]): + """Tool for running Darkmoon pentests and reading campaigns and findings from a self-hosted dashboard.""" + + def __init__(self, config: DarkmoonPentestToolConfig = DarkmoonPentestToolConfig()): + super().__init__(config) + self.base_url = (config.base_url or os.getenv("DARKMOON_BASE_URL", "")).rstrip("/") + self.username = config.username or os.getenv("DARKMOON_USERNAME", "") + self.password = config.password or os.getenv("DARKMOON_PASSWORD", "") + self.timeout = config.timeout + self.run_timeout = config.run_timeout + self.poll_interval = config.poll_interval + # Secret values to redact from any exception or log text. The login token + # is added once obtained (see _login). + self._secrets: Set[str] = {self.password} if self.password else set() + + def _scrub(self, text: str) -> str: + """Redact known secrets (password, bearer token) from diagnostic text.""" + cleaned = text + for secret in self._secrets: + cleaned = cleaned.replace(secret, "***") + return cleaned + + def __repr__(self) -> str: + return f"{type(self).__name__}(base_url={self.base_url!r}, username={self.username!r})" + + def _check_settings(self) -> None: + missing = [ + name + for name, value in ( + ("DARKMOON_BASE_URL", self.base_url), + ("DARKMOON_USERNAME", self.username), + ("DARKMOON_PASSWORD", self.password), + ) + if not value + ] + if missing: + raise ValueError( + f"Missing Darkmoon connection settings: {', '.join(missing)}. " + "Set them on DarkmoonPentestToolConfig or as environment variables." + ) + if urlsplit(self.base_url).scheme not in ("http", "https"): + raise ValueError("DARKMOON_BASE_URL must start with http:// or https://.") + + def _auth_headers(self, token: Optional[str]) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}"} if token else {} + + def _deadline(self, timeout: Optional[float]) -> float: + """Monotonic instant by which the whole request (every redirect hop included) must finish.""" + return time.monotonic() + (timeout if timeout is not None else self.timeout) + + async def _request( + self, + session: aiohttp.ClientSession, + method: str, + path: str, + body: Optional[Dict[str, Any]] = None, + token: Optional[str] = None, + timeout: Optional[float] = None, + ) -> Any: + """Send a request, resolving redirects by hand so a credentialed request is never + replayed cross-origin by the HTTP client. The redirect loop drives _send_once, which + performs one hop and reports whether another (same-origin) hop is still required. + + ``timeout`` is the budget for the WHOLE request including every redirect hop: each hop + is given only the time left before the deadline, and the deadline is rechecked before a + terminal result is accepted, so a chain of hops can never exceed the budget. + """ + headers = self._auth_headers(token) + deadline = self._deadline(timeout) + url = f"{self.base_url}{path}" + for _ in range(MAX_REDIRECTS + 1): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise asyncio.TimeoutError(f"Darkmoon API request to {path} exceeded its time budget.") + limit = aiohttp.ClientTimeout(total=remaining) + next_url, payload = await self._send_once(session, method, url, body, headers, limit) + if next_url is None: + if time.monotonic() > deadline: + raise asyncio.TimeoutError(f"Darkmoon API request to {path} exceeded its time budget.") + return payload + url = next_url + raise DarkmoonRedirectError(f"Darkmoon API exceeded {MAX_REDIRECTS} redirects for {path}.") + + async def _send_once( + self, + session: aiohttp.ClientSession, + method: str, + url: str, + body: Optional[Dict[str, Any]], + headers: Dict[str, str], + limit: aiohttp.ClientTimeout, + ) -> Tuple[Optional[str], Any]: + """Perform one request with automatic redirect following disabled. + + Returns ``(next_url, payload)``: ``next_url`` is the same-origin redirect target that + still has to be followed (``payload`` is then ``None``), or ``None`` when ``payload`` is + the final response body. A cross-origin, location-less or error response raises. + """ + async with session.request(method, url, json=body, headers=headers, timeout=limit, allow_redirects=False) as resp: + if resp.status in REDIRECT_STATUSES: + return self._same_origin_target(url, resp.headers.get("Location")), None + payload = await _read_json(resp) + if resp.status >= 400: + raise DarkmoonAPIError(resp.status, self._scrub(_error_message(payload, resp.reason))) + return None, payload + + def _same_origin_target(self, current_url: str, location: Optional[str]) -> str: + """Resolve a redirect Location, refusing any hop that would leave base_url's origin.""" + if not location: + raise DarkmoonRedirectError("Darkmoon API returned a redirect without a Location header.") + target = urljoin(current_url, location) + if _origin(target) != _origin(self.base_url): + raise DarkmoonRedirectError( + "Refusing a Darkmoon API redirect to a different origin; credentials are never forwarded cross-origin." + ) + return target + + async def _login(self, session: aiohttp.ClientSession) -> str: + credentials = {"username": self.username, "password": self.password} + payload = await self._request(session, "POST", "/api/v1/auth/login", credentials) + token = _require_str(_require_dict(payload, "login"), "token", "login") + self._secrets.add(token) # redact the bearer token from any later diagnostics + return token + + async def _verify_api_version(self, session: aiohttp.ClientSession) -> None: + """Refuse an unsupported API major. Instances without /system/info are accepted as unversioned.""" + try: + info = await self._request(session, "GET", "/api/v1/system/info") + except DarkmoonAPIError as exc: + if exc.status != 404: + raise + return + _check_api_version(info, self._scrub) + + async def _list_campaigns(self, session: aiohttp.ClientSession, token: str) -> List[Dict[str, Any]]: + return _parse_campaigns(await self._request(session, "GET", "/api/v1/campaigns", token=token)) + + async def _get_findings(self, session: aiohttp.ClientSession, token: str, campaign_id: str) -> Dict[str, Any]: + path = f"/api/v1/vulnerabilities?campaign_id={quote(campaign_id, safe='')}" + return _parse_findings(await self._request(session, "GET", path, token=token), self._scrub) + + async def _poll_run_log(self, session: aiohttp.ClientSession, token: str, run_id: str, remaining: float) -> List[Any]: + """One bounded poll of the run log. An empty list means the log does not exist yet (HTTP 404).""" + path = f"/api/v1/run/logs/{quote(run_id, safe='')}" + try: + payload = await self._request(session, "GET", path, token=token, timeout=min(self.timeout, remaining)) + except DarkmoonAPIError as exc: + if exc.status != 404: # The log file only exists once the run writes its first event. + raise + return [] + return _require_list(_require_dict(payload, "run logs"), "data", "run logs") + + async def _poll_step( + self, session: aiohttp.ClientSession, token: str, run_id: str, remaining: float + ) -> Tuple[bool, List[Any]]: + """One bounded poll. Returns (finished, events). Raises DarkmoonRunError when the run ended in failure.""" + try: + events = await self._poll_run_log(session, token, run_id, remaining) + except asyncio.TimeoutError: + if remaining >= self.timeout: + raise # The per-request timeout fired, not the run deadline: a real error, not a timed-out wait. + return False, [] + terminal = _terminal_event(events) + if terminal is None: + return False, events + self._raise_if_failed(terminal, run_id) + return True, events + + async def _wait_for_run(self, session: aiohttp.ClientSession, token: str, run_id: str) -> Tuple[bool, List[Any]]: + """Poll the run log until a terminal event. Returns (timed_out, events). Raises DarkmoonRunError on failure. + + Both the request timeout and the sleep between polls are capped by the time left before run_timeout. + """ + deadline = time.monotonic() + self.run_timeout + events: List[Any] = [] + while (remaining := deadline - time.monotonic()) > 0: + finished, events = await self._poll_step(session, token, run_id, remaining) + if finished: + return False, events + await asyncio.sleep(min(self.poll_interval, max(deadline - time.monotonic(), 0))) + return True, events + + def _raise_if_failed(self, terminal: Dict[str, Any], run_id: str) -> None: + reason = _failure_of(terminal) + if reason: + raise DarkmoonRunError(self._scrub(f"Darkmoon run {run_id} failed: {reason}.")) + + @staticmethod + def _run_body(params: DarkmoonPentestToolInputSchema) -> Dict[str, Any]: + optional = { + "program": (params.program or "").strip(), + "focus": _clean_focus(params.focus), + "severity": params.min_severity, + } + return {"target": (params.target or "").strip(), **{key: value for key, value in optional.items() if value}} + + async def _run_pentest( + self, session: aiohttp.ClientSession, token: str, params: DarkmoonPentestToolInputSchema + ) -> DarkmoonPentestToolOutputSchema: + """Start a run and (optionally) wait for it to finish, reporting only its STATUS. + + Findings are deliberately NOT attributed here: the public contract exposes no verifiable + run->campaign key (the campaign record carries no run_id/owner, and the run-start response + carries no campaign id), so guessing a campaign from a session-id window could return another + run's findings. A failed run still raises DarkmoonRunError. To read findings, call + 'list_campaigns' then 'get_findings' with the campaign id you choose. + """ + handle = await self._request(session, "POST", "/api/v1/run/campaign", self._run_body(params), token) + run_id = _require_str(_require_dict(handle, "run start"), "run_id", "run start") + result = DarkmoonPentestToolOutputSchema(action=params.action, run_id=run_id, run_status="started") + if not params.wait_for_completion: + return result + + result.timed_out, _ = await self._wait_for_run(session, token, run_id) + result.run_status = "timed_out" if result.timed_out else "completed" + return result + + async def _read_only_action( + self, session: aiohttp.ClientSession, token: str, params: DarkmoonPentestToolInputSchema + ) -> DarkmoonPentestToolOutputSchema: + if params.action == "list_campaigns": + campaigns = await self._list_campaigns(session, token) + return DarkmoonPentestToolOutputSchema( + action=params.action, + campaigns=[ + DarkmoonCampaign(id=str(c["id"]), date=str(c["date"]) if c.get("date") else None) for c in campaigns + ], + ) + campaign_id = (params.campaign_id or "").strip() + findings = await self._get_findings(session, token, campaign_id) + return DarkmoonPentestToolOutputSchema(action=params.action, campaign_id=campaign_id, **findings) + + async def run_async(self, params: DarkmoonPentestToolInputSchema) -> DarkmoonPentestToolOutputSchema: + self._check_settings() + timeout = aiohttp.ClientTimeout(total=self.timeout) + async with aiohttp.ClientSession(timeout=timeout) as session: + await self._verify_api_version(session) + token = await self._login(session) + if params.action == "run_pentest": + return await self._run_pentest(session, token, params) + return await self._read_only_action(session, token, params) + + def run(self, params: DarkmoonPentestToolInputSchema) -> DarkmoonPentestToolOutputSchema: + with ThreadPoolExecutor() as executor: + return executor.submit(asyncio.run, self.run_async(params)).result() + + +################# +# EXAMPLE USAGE # +################# +if __name__ == "__main__": # pragma: no cover + from dotenv import load_dotenv + from rich.console import Console + + load_dotenv() + console = Console() + tool = DarkmoonPentestTool() + + campaigns = tool.run(DarkmoonPentestToolInputSchema(action="list_campaigns")) + console.print(f"[bold]{len(campaigns.campaigns)} campaigns[/bold]") + for campaign in campaigns.campaigns[:5]: + console.print(campaign.id) + + if campaigns.campaigns: + report = tool.run(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=campaigns.campaigns[0].id)) + console.rule(f"{report.total} findings") + for finding in report.findings[:10]: + console.print(f"[bold]{finding.severity}[/bold] {finding.title} {finding.endpoint or ''}") diff --git a/docs/guides/tools.md b/docs/guides/tools.md index 5d290363..7a0f2a6b 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -148,6 +148,7 @@ The Atomic Forge ships with several pre-built tools: - **arXiv Search**: Search arXiv for academic papers (free public API) - **BoCha Search**: Web search - **Calculator**: Perform mathematical calculations +- **Darkmoon Pentest**: Run Darkmoon autonomous pentests and read campaigns and findings from a self-hosted Darkmoon dashboard (a Pro feature) - **DateTime**: Timezone-aware now / parse / convert / shift / diff (no key required) - **Fía Signals**: Crypto market intelligence — market regime, trading signals, DeFi yields, gas prices, Solana trending tokens, and wallet risk scoring - **Hacker News Search**: Search HN stories, comments, Show HN, Ask HN, polls (free Algolia API) diff --git a/uv.lock b/uv.lock index 6c39d504..51ad339c 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ members = [ "basic-pdf-analysis", "bocha-search", "calculator", + "darkmoon-pentest", "datetime-tool", "deep-research", "dspy-integration", @@ -894,6 +895,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/e8/77a231ae531cf38765b75ddf27dae28bb5f70b41d8bb4f15ce1650e93f57/cyclopts-4.3.0-py3-none-any.whl", hash = "sha256:91a30b69faf128ada7cfeaefd7d9649dc222e8b2a8697f1fc99e4ee7b7ca44f3", size = 187184, upload-time = "2025-11-25T02:59:32.21Z" }, ] +[[package]] +name = "darkmoon-pentest" +version = "1.0.0" +source = { editable = "atomic-forge/tools/darkmoon_pentest" } +dependencies = [ + { name = "aiohttp" }, + { name = "atomic-agents" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "python-dotenv" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0,<4.0.0" }, + { name = "atomic-agents", editable = "." }, + { name = "pydantic", specifier = ">=2.10.3,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=7.6.1,<8.0.0" }, + { name = "pytest", specifier = ">=8.3.3,<9.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.5,<1.0.0" }, + { name = "pytest-cov", specifier = ">=5.0.0,<6.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" }, + { name = "rich", specifier = ">=13.7.0,<14.0.0" }, +] + [[package]] name = "dateparser" version = "1.2.2"