From b3badb785596ba1599fbc62c653f4cc1cdea9b49 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:01 +0200 Subject: [PATCH 01/15] feat(forge): add Darkmoon pentest tool --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) 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 From ac83d39ccf53cbeb7d4732ff26d1aa5d3410c366 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:02 +0200 Subject: [PATCH 02/15] feat(forge): add Darkmoon pentest tool --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From 71fc701c74c1caeabba1fc22f2f94611c1720fbe Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:03 +0200 Subject: [PATCH 03/15] feat(forge): add Darkmoon pentest tool --- atomic-forge/README.md | 1 + 1 file changed, 1 insertion(+) 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. From 27ed03564b0e64c0ca3d673d3d5a067946c369b5 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:04 +0200 Subject: [PATCH 04/15] feat(forge): add Darkmoon pentest tool --- atomic-forge/tools/darkmoon_pentest/.coveragerc | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 atomic-forge/tools/darkmoon_pentest/.coveragerc 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 From 2af98b6313d09c66a57dfd8bd4560b042d637045 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:06 +0200 Subject: [PATCH 05/15] feat(forge): add Darkmoon pentest tool --- atomic-forge/tools/darkmoon_pentest/README.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 atomic-forge/tools/darkmoon_pentest/README.md diff --git a/atomic-forge/tools/darkmoon_pentest/README.md b/atomic-forge/tools/darkmoon_pentest/README.md new file mode 100644 index 00000000..bc55c5c2 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/README.md @@ -0,0 +1,76 @@ +# 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. + +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 and return its findings (default `True`). +- `program` (str), `focus` (list[str]), `min_severity` (`info`, `low`, `medium`, `high`, `critical`): optional run settings. + +### Output Schema +- `action`, `run_id`, `campaign_id`, `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`) + +After a run, the tool reports the campaign that the run created (campaigns that existed before the run are never returned as its result). If the wait times out, `timed_out` is `True` and the run keeps going on the Darkmoon side. + +## 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. From 6af179ceff444fc5ac9e0db0dc13365cee3d9ceb Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:07 +0200 Subject: [PATCH 06/15] feat(forge): add Darkmoon pentest tool --- .../tools/darkmoon_pentest/pyproject.toml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 atomic-forge/tools/darkmoon_pentest/pyproject.toml 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 } From f827677c9baad58df6060e68649871b915ae5938 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:08 +0200 Subject: [PATCH 07/15] feat(forge): add Darkmoon pentest tool --- atomic-forge/tools/darkmoon_pentest/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 atomic-forge/tools/darkmoon_pentest/requirements.txt 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 From 909dca9219126be38bb41720fab0ff2d72b90e89 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:09 +0200 Subject: [PATCH 08/15] feat(forge): add Darkmoon pentest tool --- .../tests/test_darkmoon_pentest.py | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py 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..38aa38c7 --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py @@ -0,0 +1,254 @@ +import asyncio +import os +import sys +import threading + +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 + DarkmoonFinding, + DarkmoonPentestTool, + DarkmoonPentestToolConfig, + DarkmoonPentestToolInputSchema, + DarkmoonPentestToolOutputSchema, +) + + +FINDING = { + "title": "SQL injection in /api/items", + "severity": "high", + "cvss_score": 8.1, + "category": "sql_injection", + "status": "confirmed", + "endpoint": "https://target.example/api/items?id=1", + "cve": None, + "description": "The id parameter is concatenated into a query.", + "remediation": "Use parameterised queries.", + "evidence_logs": "ignored extra field", +} + + +class FakeDarkmoon: + """Minimal in-process stand-in for the Darkmoon Dashboard API routes the tool uses.""" + + def __init__(self, run_polls_before_done: int = 1): + self.token = "jwt-123" + self.campaigns = [{"id": "camp_20260101_old", "date": "2026-01-01"}] + self.polls_left = run_polls_before_done + self.run_body = None + self.requests = [] + + def _authorised(self, request: web.Request) -> bool: + return request.headers.get("Authorization") == f"Bearer {self.token}" + + async def login(self, request: web.Request) -> web.Response: + body = await request.json() + self.requests.append(("login", body)) + if body != {"username": "analyst", "password": "s3cret"}: + return web.json_response({"detail": "Invalid credentials"}, status=401) + return web.json_response({"token": self.token}) + + async def list_campaigns(self, request: web.Request) -> web.Response: + if not self._authorised(request): + return web.json_response({"detail": "Not authenticated"}, status=401) + return web.json_response({"data": self.campaigns}) + + async def vulnerabilities(self, request: web.Request) -> web.Response: + if not self._authorised(request): + return web.json_response({"detail": "Not authenticated"}, status=401) + self.requests.append(("vulnerabilities", request.query["campaign_id"])) + if request.query["campaign_id"] == "camp_20260101_old": + return web.json_response({"total": 0, "stats": {}, "data": []}) + return web.json_response({"total": 1, "stats": {"high": 1}, "data": [FINDING, {"severity": "low"}]}) + + async def run_campaign(self, request: web.Request) -> web.Response: + if not self._authorised(request): + return web.json_response({"detail": "Not authenticated"}, status=401) + self.run_body = await request.json() + self.campaigns.append({"id": "camp_20260922_target-example", "date": "2026-09-22"}) + return web.json_response({"run_id": "run_42"}) + + async def run_logs(self, request: web.Request) -> web.Response: + if self.polls_left > 0: + self.polls_left -= 1 + if self.polls_left == 0: + return web.json_response({"detail": "Log not found"}, status=404) + return web.json_response({"data": [{"type": "run_started"}]}) + return web.json_response({"data": [{"type": "run_started"}, {"type": "run_completed"}]}) + + +async def start_server(fake: FakeDarkmoon): + app = web.Application() + app.router.add_post("/api/v1/auth/login", fake.login) + app.router.add_get("/api/v1/campaigns", fake.list_campaigns) + app.router.add_get("/api/v1/vulnerabilities", fake.vulnerabilities) + app.router.add_post("/api/v1/run/campaign", fake.run_campaign) + app.router.add_get("/api/v1/run/logs/{run_id}", fake.run_logs) + 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}/" + + +@pytest_asyncio.fixture +async def darkmoon(): + fake = FakeDarkmoon() + runner, base_url = await start_server(fake) + config = DarkmoonPentestToolConfig( + base_url=base_url, username="analyst", password="s3cret", poll_interval=0.01, run_timeout=5 + ) + yield fake, DarkmoonPentestTool(config=config) + 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() + fake = FakeDarkmoon() + runner, base_url = asyncio.run_coroutine_threadsafe(start_server(fake), loop).result(timeout=10) + config = DarkmoonPentestToolConfig(base_url=base_url, username="analyst", password="s3cret") + yield DarkmoonPentestTool(config=config) + asyncio.run_coroutine_threadsafe(runner.cleanup(), loop).result(timeout=10) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=10) + loop.close() + + +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 + + +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_list_campaigns(darkmoon): + _, tool = darkmoon + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert [c.id for c in output.campaigns] == ["camp_20260101_old"] + assert output.campaigns[0].date == "2026-01-01" + + +@pytest.mark.asyncio +async def test_get_findings_drops_untitled_and_unknown_fields(darkmoon): + fake, tool = darkmoon + output = await tool.run_async( + DarkmoonPentestToolInputSchema(action="get_findings", campaign_id="camp_20260922_target-example") + ) + assert output.total == 1 + assert output.stats == {"high": 1} + assert len(output.findings) == 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", "camp_20260922_target-example") in fake.requests + + +@pytest.mark.asyncio +async def test_run_pentest_waits_and_returns_new_campaign_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", + } + assert output.run_id == "run_42" + assert output.timed_out is False + assert output.campaign_id == "camp_20260922_target-example" + assert output.total == 1 and output.findings[0].category == "sql_injection" + # The stale campaign must never be reported as the result of this run. + assert ("vulnerabilities", "camp_20260101_old") not in fake.requests + + +@pytest.mark.asyncio +async def test_run_pentest_without_waiting(darkmoon): + fake, tool = darkmoon + output = await tool.run_async( + DarkmoonPentestToolInputSchema(action="run_pentest", target="target.example", wait_for_completion=False) + ) + assert output.run_id == "run_42" + assert output.findings == [] and output.campaign_id is None + + +@pytest.mark.asyncio +async def test_run_pentest_times_out(darkmoon): + fake, tool = darkmoon + fake.polls_left = 10_000 + tool.run_timeout = 0.05 + output = await tool.run_async(DarkmoonPentestToolInputSchema(action="run_pentest", target="target.example")) + assert output.timed_out is True + assert output.run_id == "run_42" + + +@pytest.mark.asyncio +async def test_bad_credentials_report_api_detail(darkmoon): + _, tool = darkmoon + tool.password = "wrong" + with pytest.raises(RuntimeError, match="401: Invalid credentials"): + await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) + + +def test_pick_new_campaign_prefers_target_match(): + campaigns = [ + {"id": "camp_a_other", "date": "2026-09-23"}, + {"id": "camp_b_target-example", "date": "2026-09-22"}, + {"id": "camp_old", "date": "2026-01-01"}, + ] + picked = DarkmoonPentestTool._pick_new_campaign(campaigns, {"camp_old"}, "Target-Example") + assert picked["id"] == "camp_b_target-example" + assert DarkmoonPentestTool._pick_new_campaign(campaigns, {c["id"] for c in campaigns}, "x") is None + + +def test_run_sync_wrapper(darkmoon_sync): + tool = darkmoon_sync + output = tool.run(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert output.campaigns[0].id == "camp_20260101_old" From 46af3d9301016eb4821ad72063b77d57c1b012fa Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:10 +0200 Subject: [PATCH 09/15] feat(forge): add Darkmoon pentest tool --- .../darkmoon_pentest/tool/darkmoon_pentest.py | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py 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..5123c75a --- /dev/null +++ b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py @@ -0,0 +1,294 @@ +import asyncio +import os +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Literal, Optional +from urllib.parse import quote + +import aiohttp +from pydantic import Field, model_validator + +from atomic_agents import BaseIOSchema, BaseTool, BaseToolConfig + + +TERMINAL_EVENTS = frozenset({"run_completed", "run_error"}) + + +################ +# 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 findings), 'list_campaigns' to list existing campaigns, and 'get_findings' + to read the vulnerabilities and severity statistics of one campaign. 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 findings. 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": + if self.action == "run_pentest" and not (self.target or "").strip(): + raise ValueError("'target' is required for action 'run_pentest'.") + if self.action == "get_findings" and not (self.campaign_id or "").strip(): + raise ValueError("'campaign_id' is required for action 'get_findings'.") + 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, 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.") + campaign_id: Optional[str] = Field(None, description="Campaign the findings belong to.") + 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="", description="Dashboard password. Falls back to DARKMOON_PASSWORD.") + 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.") + + +##################### +# 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 + + 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." + ) + + async def _request( + self, + session: aiohttp.ClientSession, + method: str, + path: str, + body: Optional[Dict[str, Any]] = None, + token: Optional[str] = None, + ) -> Any: + headers = {"Authorization": f"Bearer {token}"} if token else {} + async with session.request(method, f"{self.base_url}{path}", json=body, headers=headers) as resp: + try: + payload = await resp.json(content_type=None) + except ValueError: + payload = None + if resp.status >= 400: + detail = payload.get("detail") if isinstance(payload, dict) else None + message = detail if isinstance(detail, str) and detail else resp.reason + raise RuntimeError(f"Darkmoon API error {resp.status}: {message}") + return payload + + async def _login(self, session: aiohttp.ClientSession) -> str: + payload = await self._request( + session, "POST", "/api/v1/auth/login", {"username": self.username, "password": self.password} + ) + token = payload.get("token") if isinstance(payload, dict) else None + if not token: + raise RuntimeError("Darkmoon login did not return a token.") + return str(token) + + async def _list_campaigns(self, session: aiohttp.ClientSession, token: str) -> List[Dict[str, Any]]: + payload = await self._request(session, "GET", "/api/v1/campaigns", token=token) + data = payload.get("data") if isinstance(payload, dict) else None + return [c for c in data if isinstance(c, dict) and c.get("id")] if isinstance(data, list) else [] + + 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='')}" + payload = await self._request(session, "GET", path, token=token) + body = payload if isinstance(payload, dict) else {} + findings = [f for f in (body.get("data") or []) if isinstance(f, dict) and f.get("title")] + return { + "total": body.get("total") or len(findings), + "stats": body.get("stats") or {}, + "findings": [ + DarkmoonFinding(**{k: v for k, v in f.items() if k in DarkmoonFinding.model_fields}) for f in findings + ], + } + + async def _wait_for_run(self, session: aiohttp.ClientSession, token: str, run_id: str) -> bool: + """Poll the run log until a terminal event. Returns True when the wait timed out.""" + deadline = time.monotonic() + self.run_timeout + path = f"/api/v1/run/logs/{quote(run_id, safe='')}" + while True: + try: + payload = await self._request(session, "GET", path, token=token) + except RuntimeError as exc: + # The log file does not exist until the run writes its first event. + if "404" not in str(exc): + raise + payload = None + events = payload.get("data") if isinstance(payload, dict) else None + if any(isinstance(e, dict) and e.get("type") in TERMINAL_EVENTS for e in events or []): + return False + if time.monotonic() >= deadline: + return True + await asyncio.sleep(self.poll_interval) + + @staticmethod + def _pick_new_campaign(campaigns: List[Dict[str, Any]], known_ids: set, target: str) -> Optional[Dict[str, Any]]: + """Find the campaign minted by the run. Only campaigns that did not exist before it are considered.""" + fresh = [c for c in campaigns if c["id"] not in known_ids] + if not fresh: + return None + fresh.sort(key=lambda c: str(c.get("date") or ""), reverse=True) + needle = target.lower() + return next((c for c in fresh if needle in str(c["id"]).lower()), fresh[0]) + + async def _run_pentest( + self, session: aiohttp.ClientSession, token: str, params: DarkmoonPentestToolInputSchema + ) -> DarkmoonPentestToolOutputSchema: + target = (params.target or "").strip() + body: Dict[str, Any] = {"target": target} + if params.program and params.program.strip(): + body["program"] = params.program.strip() + if params.focus: + body["focus"] = [f.strip() for f in params.focus if f.strip()] + if params.min_severity: + body["severity"] = params.min_severity + + known_ids = {c["id"] for c in await self._list_campaigns(session, token)} + handle = await self._request(session, "POST", "/api/v1/run/campaign", body, token) + run_id = handle.get("run_id") if isinstance(handle, dict) else None + if not run_id: + raise RuntimeError("Darkmoon did not return a run id.") + result = DarkmoonPentestToolOutputSchema(action=params.action, run_id=str(run_id)) + if not params.wait_for_completion: + return result + + result.timed_out = await self._wait_for_run(session, token, str(run_id)) + campaign = self._pick_new_campaign(await self._list_campaigns(session, token), known_ids, target) + if campaign: + result.campaign_id = str(campaign["id"]) + findings = await self._get_findings(session, token, result.campaign_id) + result.total, result.stats, result.findings = findings["total"], findings["stats"], findings["findings"] + return result + + 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: + token = await self._login(session) + 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 + ], + ) + if params.action == "get_findings": + 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) + return await self._run_pentest(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 ''}") From 926b90f350b4e4cd2f9a2ec97dfbe7497eedd517 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:11 +0200 Subject: [PATCH 10/15] feat(forge): add Darkmoon pentest tool --- docs/guides/tools.md | 1 + 1 file changed, 1 insertion(+) 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) From 7f0dd2518d5ac3658190942450b14c2db83d2bd0 Mon Sep 17 00:00:00 2001 From: mehdi B <41642346+MBK-fr@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:13 +0200 Subject: [PATCH 11/15] feat(forge): add Darkmoon pentest tool --- uv.lock | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) 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" From 00611572da84a89950a21895c24d58f96756a8bc Mon Sep 17 00:00:00 2001 From: MBK-fr <41642346+MBK-fr@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:38:48 +0200 Subject: [PATCH 12/15] darkmoon_pentest: address review (run correlation, failures, deadline, status codes, schema checks) - Correlate a run to its campaign through the run's own opencode session id (run log sessionID vs campaign session_id, the rule the Dashboard API uses), never by "newest campaign". 0 or >1 matches raises DarkmoonCorrelationError. - run_error, aborted runs and non-zero exit codes raise DarkmoonRunError. - Cap both poll sleeps and poll requests by the time left before run_timeout. - Use the real HTTP status (DarkmoonAPIError.status) instead of matching "404" in the message. - Validate response shapes (login, run start, run log, campaigns, findings); malformed envelopes raise DarkmoonSchemaError. - Keep password out of repr; enforce cvss_score in [0, 10]; require http(s) URL. - Check /api/v1/system/info and refuse unsupported API majors; pin API major 1. - Add regression tests, contract fixtures, TLS/secret/cancellation tests. - Refactor into helpers: every function has cyclomatic complexity < 6. --- atomic-forge/tools/darkmoon_pentest/README.md | 15 +- .../darkmoon_pentest/tests/fixtures/README.md | 18 + .../tests/fixtures/campaigns.json | 4 + .../tests/fixtures/login.json | 1 + .../tests/fixtures/run_campaign.json | 1 + .../tests/fixtures/run_logs_aborted.json | 5 + .../tests/fixtures/run_logs_completed.json | 6 + .../tests/fixtures/run_logs_error.json | 5 + .../tests/fixtures/system_info.json | 1 + .../tests/fixtures/vulnerabilities.json | 3 + .../tests/test_darkmoon_pentest.py | 530 ++++++++++++++---- .../darkmoon_pentest/tool/darkmoon_pentest.py | 362 ++++++++---- 12 files changed, 749 insertions(+), 202 deletions(-) create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/README.md create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/campaigns.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/login.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_campaign.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_aborted.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_completed.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/run_logs_error.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/system_info.json create mode 100644 atomic-forge/tools/darkmoon_pentest/tests/fixtures/vulnerabilities.json diff --git a/atomic-forge/tools/darkmoon_pentest/README.md b/atomic-forge/tools/darkmoon_pentest/README.md index bc55c5c2..1e99e262 100644 --- a/atomic-forge/tools/darkmoon_pentest/README.md +++ b/atomic-forge/tools/darkmoon_pentest/README.md @@ -7,6 +7,8 @@ The tool logs in with `POST /api/v1/auth/login`, then uses the returned token fo **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 refuses to run against any other major version; an instance that does not expose `/system/info` is treated as unversioned and accepted. 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 @@ -43,7 +45,18 @@ Only run assessments against systems you own or are explicitly authorised to tes - `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`) -After a run, the tool reports the campaign that the run created (campaigns that existed before the run are never returned as its result). If the wait times out, `timed_out` is `True` and the run keeps going on the Darkmoon side. +`POST /api/v1/run/campaign` returns only a `run_id`, so the tool correlates the run to its campaign through the run's own opencode session id: the first `sessionID` in the run log (`GET /api/v1/run/logs/{run_id}`) must contain the campaign's `session_id`, which is the rule the Dashboard API itself uses to finalise a run's campaign. The newest or most recent campaign is never used. If zero or more than one campaign matches, or the log carries no session id, the tool raises `DarkmoonCorrelationError` and you can fall back to `list_campaigns` and `get_findings`. + +Failure 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, an unsupported API major version). +- `DarkmoonCorrelationError`: see above. + +`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. If the wait times out, `timed_out` is `True`, no findings are returned, and the run keeps going on the Darkmoon side (read it later with `list_campaigns` and `get_findings`). TLS certificates are always verified, and `password` is excluded from the config `repr`. + +## 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 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 index 38aa38c7..7d3becbf 100644 --- a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py @@ -1,8 +1,16 @@ 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 @@ -11,100 +19,116 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from tool.darkmoon_pentest import ( # noqa: E402 + DarkmoonAPIError, + DarkmoonCorrelationError, DarkmoonFinding, DarkmoonPentestTool, DarkmoonPentestToolConfig, DarkmoonPentestToolInputSchema, DarkmoonPentestToolOutputSchema, + DarkmoonRunError, + DarkmoonSchemaError, ) +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") -FINDING = { - "title": "SQL injection in /api/items", - "severity": "high", - "cvss_score": 8.1, - "category": "sql_injection", - "status": "confirmed", - "endpoint": "https://target.example/api/items?id=1", - "cve": None, - "description": "The id parameter is concatenated into a query.", - "remediation": "Use parameterised queries.", - "evidence_logs": "ignored extra field", -} + +def fixture(name): + return json.loads((FIXTURES / name).read_text()) class FakeDarkmoon: - """Minimal in-process stand-in for the Darkmoon Dashboard API routes the tool uses.""" + """In-process stand-in for the Dashboard API routes the tool uses. Bodies come from the contract fixtures.""" - def __init__(self, run_polls_before_done: int = 1): + def __init__(self): self.token = "jwt-123" - self.campaigns = [{"id": "camp_20260101_old", "date": "2026-01-01"}] - self.polls_left = run_polls_before_done + 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: web.Request) -> bool: + def _authorised(self, request): return request.headers.get("Authorization") == f"Bearer {self.token}" - async def login(self, request: web.Request) -> web.Response: + async def login(self, request): body = await request.json() self.requests.append(("login", body)) - if body != {"username": "analyst", "password": "s3cret"}: + if body != {"username": "analyst", "password": PASSWORD}: return web.json_response({"detail": "Invalid credentials"}, status=401) - return web.json_response({"token": self.token}) + 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: web.Request) -> web.Response: + async def list_campaigns(self, request): if not self._authorised(request): return web.json_response({"detail": "Not authenticated"}, status=401) - return web.json_response({"data": self.campaigns}) + self.requests.append(("campaigns", None)) + return web.json_response(self.campaigns) - async def vulnerabilities(self, request: web.Request) -> web.Response: + 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"])) - if request.query["campaign_id"] == "camp_20260101_old": - return web.json_response({"total": 0, "stats": {}, "data": []}) - return web.json_response({"total": 1, "stats": {"high": 1}, "data": [FINDING, {"severity": "low"}]}) + return web.json_response(self.vulns) - async def run_campaign(self, request: web.Request) -> web.Response: + 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.campaigns.append({"id": "camp_20260922_target-example", "date": "2026-09-22"}) - return web.json_response({"run_id": "run_42"}) - - async def run_logs(self, request: web.Request) -> web.Response: - if self.polls_left > 0: - self.polls_left -= 1 - if self.polls_left == 0: - return web.json_response({"detail": "Log not found"}, status=404) - return web.json_response({"data": [{"type": "run_started"}]}) - return web.json_response({"data": [{"type": "run_started"}, {"type": "run_completed"}]}) - - -async def start_server(fake: FakeDarkmoon): - app = web.Application() - app.router.add_post("/api/v1/auth/login", fake.login) - app.router.add_get("/api/v1/campaigns", fake.list_campaigns) - app.router.add_get("/api/v1/vulnerabilities", fake.vulnerabilities) - app.router.add_post("/api/v1/run/campaign", fake.run_campaign) - app.router.add_get("/api/v1/run/logs/{run_id}", fake.run_logs) - runner = web.AppRunner(app) + 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) + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_context) await site.start() port = site._server.sockets[0].getsockname()[1] - return runner, f"http://127.0.0.1:{port}/" + 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) - config = DarkmoonPentestToolConfig( - base_url=base_url, username="analyst", password="s3cret", poll_interval=0.01, run_timeout=5 - ) - yield fake, DarkmoonPentestTool(config=config) + yield fake, make_tool(base_url) await runner.cleanup() @@ -114,16 +138,21 @@ def darkmoon_sync(): loop = asyncio.new_event_loop() thread = threading.Thread(target=loop.run_forever, daemon=True) thread.start() - fake = FakeDarkmoon() - runner, base_url = asyncio.run_coroutine_threadsafe(start_server(fake), loop).result(timeout=10) - config = DarkmoonPentestToolConfig(base_url=base_url, username="analyst", password="s3cret") - yield DarkmoonPentestTool(config=config) + 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") @@ -137,6 +166,17 @@ def test_input_schema_requires_campaign_for_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 @@ -161,38 +201,171 @@ async def test_missing_settings_raise(monkeypatch): 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_old"] - assert output.campaigns[0].date == "2026-01-01" + 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_drops_untitled_and_unknown_fields(darkmoon): +async def test_get_findings_keeps_known_fields_only(darkmoon): fake, tool = darkmoon - output = await tool.run_async( - DarkmoonPentestToolInputSchema(action="get_findings", campaign_id="camp_20260922_target-example") - ) - assert output.total == 1 - assert output.stats == {"high": 1} - assert len(output.findings) == 1 + 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", "camp_20260922_target-example") in fake.requests + 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 + + async def no_token(*args, **kwargs): + return {"user": "analyst"} + + 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_run_pentest_waits_and_returns_new_campaign_findings(darkmoon): +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_returns_the_runs_own_campaign(darkmoon): fake, tool = darkmoon params = DarkmoonPentestToolInputSchema( - action="run_pentest", - target="target.example", - program="Q3 scope", - focus=["auth", " injection "], - min_severity="medium", + 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 == { @@ -201,54 +374,201 @@ async def test_run_pentest_waits_and_returns_new_campaign_findings(darkmoon): "focus": ["auth", "injection"], "severity": "medium", } - assert output.run_id == "run_42" - assert output.timed_out is False - assert output.campaign_id == "camp_20260922_target-example" + assert (output.run_id, output.timed_out, output.campaign_id) == (RUN_ID, False, CAMPAIGN_ID) assert output.total == 1 and output.findings[0].category == "sql_injection" - # The stale campaign must never be reported as the result of this run. - assert ("vulnerabilities", "camp_20260101_old") not in fake.requests + assert ("vulnerabilities", "camp_20260101_01d5e551") not in fake.requests @pytest.mark.asyncio -async def test_run_pentest_without_waiting(darkmoon): +async def test_concurrent_run_campaign_is_never_attached_to_this_run(darkmoon): + """A newer campaign for the same target started by another run must not be picked up.""" fake, tool = darkmoon - output = await tool.run_async( - DarkmoonPentestToolInputSchema(action="run_pentest", target="target.example", wait_for_completion=False) - ) - assert output.run_id == "run_42" + decoy = {"id": "camp_20261004_99999999", "session_id": "99999999", "date": "2026-10-04T10:40:00+00:00"} + fake.campaigns["data"].append(decoy) + output = await tool.run_async(RUN_INPUT) + assert output.campaign_id == CAMPAIGN_ID + assert ("vulnerabilities", decoy["id"]) not 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 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.campaign_id == CAMPAIGN_ID + assert fake.count("logs") == 3 + + +@pytest.mark.asyncio +async def test_finished_run_with_no_matching_campaign_is_an_error(darkmoon): + fake, tool = darkmoon + fake.campaigns["data"] = [c for c in fake.campaigns["data"] if c["id"] != CAMPAIGN_ID] + with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): + await tool.run_async(RUN_INPUT) + assert fake.count("vulnerabilities") == 0 + + +@pytest.mark.asyncio +async def test_finished_run_with_two_matching_campaigns_is_an_error(darkmoon): + fake, tool = darkmoon + fake.campaigns["data"].append({"id": "camp_20261004_twin", "session_id": "5f3a9c1e"}) + with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): + await tool.run_async(RUN_INPUT) + assert fake.count("vulnerabilities") == 0 + + +@pytest.mark.asyncio +async def test_run_log_without_session_id_is_an_error(darkmoon): + fake, tool = darkmoon + fake.log_replies = [logs_with({"type": "run_started"}, {"type": "run_completed", "exit_code": 0})] + with pytest.raises(DarkmoonCorrelationError, match="session id"): + await tool.run_async(RUN_INPUT) + + +################################ +# 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_run_pentest_times_out(darkmoon): +async def test_nonzero_exit_code_is_a_failure(darkmoon): fake, tool = darkmoon - fake.polls_left = 10_000 - tool.run_timeout = 0.05 - output = await tool.run_async(DarkmoonPentestToolInputSchema(action="run_pentest", target="target.example")) + 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 output.run_id == "run_42" + assert time.monotonic() - started < 2.5 @pytest.mark.asyncio -async def test_bad_credentials_report_api_detail(darkmoon): - _, tool = darkmoon - tool.password = "wrong" - with pytest.raises(RuntimeError, match="401: Invalid credentials"): - await tool.run_async(DarkmoonPentestToolInputSchema(action="list_campaigns")) +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) -def test_pick_new_campaign_prefers_target_match(): - campaigns = [ - {"id": "camp_a_other", "date": "2026-09-23"}, - {"id": "camp_b_target-example", "date": "2026-09-22"}, - {"id": "camp_old", "date": "2026-01-01"}, - ] - picked = DarkmoonPentestTool._pick_new_campaign(campaigns, {"camp_old"}, "Target-Example") - assert picked["id"] == "camp_b_target-example" - assert DarkmoonPentestTool._pick_new_campaign(campaigns, {c["id"] for c in campaigns}, "x") is None +@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): - tool = darkmoon_sync - output = tool.run(DarkmoonPentestToolInputSchema(action="list_campaigns")) - assert output.campaigns[0].id == "camp_20260101_old" + output = darkmoon_sync.run(DarkmoonPentestToolInputSchema(action="list_campaigns")) + assert output.campaigns[0].id == "camp_20260101_01d5e551" diff --git a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py index 5123c75a..f84eabcd 100644 --- a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py @@ -2,16 +2,43 @@ import os import time from concurrent.futures import ThreadPoolExecutor -from typing import Any, Dict, List, Literal, Optional -from urllib.parse import quote +from typing import Any, Dict, List, Literal, Optional, Tuple +from urllib.parse import quote, urlsplit import aiohttp -from pydantic import Field, model_validator +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. +MAX_ERROR_CHARS = 300 +REQUIRED_FIELD = {"run_pentest": "target", "get_findings": "campaign_id"} + + +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 DarkmoonCorrelationError(RuntimeError): + """A finished run could not be attributed to exactly one campaign, so no findings are returned for it.""" ################ @@ -53,10 +80,9 @@ class DarkmoonPentestToolInputSchema(BaseIOSchema): @model_validator(mode="after") def _check_required_fields(self) -> "DarkmoonPentestToolInputSchema": - if self.action == "run_pentest" and not (self.target or "").strip(): - raise ValueError("'target' is required for action 'run_pentest'.") - if self.action == "get_findings" and not (self.campaign_id or "").strip(): - raise ValueError("'campaign_id' is required for action 'get_findings'.") + 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 @@ -75,7 +101,7 @@ class DarkmoonFinding(BaseIOSchema): title: str = Field(..., description="Finding title.") severity: Optional[str] = Field(None, description="critical, high, medium, low or info.") - cvss_score: Optional[float] = Field(None, description="CVSS score between 0 and 10.") + 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.") @@ -105,12 +131,118 @@ class DarkmoonPentestToolConfig(BaseToolConfig): 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="", description="Dashboard password. Falls back to DARKMOON_PASSWORD.") + 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 _owns_session(campaign: Dict[str, Any], session_id: str) -> bool: + """True when the campaign's 8-char session_id is a window of the run's parent session id.""" + fragment = campaign.get("session_id") + return isinstance(fragment, str) and len(fragment) >= 8 and fragment in session_id + + +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 _parse_findings(payload: Any) -> Dict[str, Any]: + body = _require_dict(payload, "vulnerabilities") + findings = [_parse_finding(item) for item in _require_list(body, "data", "vulnerabilities")] + total = body.get("total", len(findings)) + stats = body.get("stats", {}) + if isinstance(total, bool) or not isinstance(total, int) or not isinstance(stats, dict): + raise DarkmoonSchemaError("Unexpected Darkmoon response for vulnerabilities: bad 'total' or 'stats'.") + return {"total": total, "stats": stats, "findings": findings} + + +def _first_session_id(events: List[Any]) -> Optional[str]: + """The parent opencode session id: the first top-level sessionID in the run log (as the Dashboard API uses it).""" + for event in events: + session_id = event.get("sessionID") if isinstance(event, dict) else None + if isinstance(session_id, str) and session_id: + return session_id.replace("ses_", "").replace("-", "") + return None + + +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 _check_api_version(info: Any) -> None: + version = info.get("api_version") if isinstance(info, dict) else None + major = str(version or "").split(".")[0].lstrip("vV") + if major.isdigit() and int(major) != SUPPORTED_API_MAJOR: + raise DarkmoonSchemaError( + f"Unsupported Darkmoon Dashboard API version {version}; this tool supports major {SUPPORTED_API_MAJOR}." + ) + + ##################### # MAIN TOOL & LOGIC # ##################### @@ -126,6 +258,9 @@ def __init__(self, config: DarkmoonPentestToolConfig = DarkmoonPentestToolConfig self.run_timeout = config.run_timeout self.poll_interval = config.poll_interval + 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 @@ -141,6 +276,8 @@ def _check_settings(self) -> None: 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://.") async def _request( self, @@ -149,122 +286,155 @@ async def _request( path: str, body: Optional[Dict[str, Any]] = None, token: Optional[str] = None, + timeout: Optional[float] = None, ) -> Any: headers = {"Authorization": f"Bearer {token}"} if token else {} - async with session.request(method, f"{self.base_url}{path}", json=body, headers=headers) as resp: - try: - payload = await resp.json(content_type=None) - except ValueError: - payload = None + limit = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) + async with session.request(method, f"{self.base_url}{path}", json=body, headers=headers, timeout=limit) as resp: + payload = await _read_json(resp) if resp.status >= 400: - detail = payload.get("detail") if isinstance(payload, dict) else None - message = detail if isinstance(detail, str) and detail else resp.reason - raise RuntimeError(f"Darkmoon API error {resp.status}: {message}") + raise DarkmoonAPIError(resp.status, _error_message(payload, resp.reason)) return payload async def _login(self, session: aiohttp.ClientSession) -> str: - payload = await self._request( - session, "POST", "/api/v1/auth/login", {"username": self.username, "password": self.password} - ) - token = payload.get("token") if isinstance(payload, dict) else None - if not token: - raise RuntimeError("Darkmoon login did not return a token.") - return str(token) + credentials = {"username": self.username, "password": self.password} + payload = await self._request(session, "POST", "/api/v1/auth/login", credentials) + return _require_str(_require_dict(payload, "login"), "token", "login") + + 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) async def _list_campaigns(self, session: aiohttp.ClientSession, token: str) -> List[Dict[str, Any]]: - payload = await self._request(session, "GET", "/api/v1/campaigns", token=token) - data = payload.get("data") if isinstance(payload, dict) else None - return [c for c in data if isinstance(c, dict) and c.get("id")] if isinstance(data, list) else [] + 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='')}" - payload = await self._request(session, "GET", path, token=token) - body = payload if isinstance(payload, dict) else {} - findings = [f for f in (body.get("data") or []) if isinstance(f, dict) and f.get("title")] - return { - "total": body.get("total") or len(findings), - "stats": body.get("stats") or {}, - "findings": [ - DarkmoonFinding(**{k: v for k, v in f.items() if k in DarkmoonFinding.model_fields}) for f in findings - ], - } + return _parse_findings(await self._request(session, "GET", path, token=token)) - async def _wait_for_run(self, session: aiohttp.ClientSession, token: str, run_id: str) -> bool: - """Poll the run log until a terminal event. Returns True when the wait timed out.""" - deadline = time.monotonic() + self.run_timeout + 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='')}" - while True: - try: - payload = await self._request(session, "GET", path, token=token) - except RuntimeError as exc: - # The log file does not exist until the run writes its first event. - if "404" not in str(exc): - raise - payload = None - events = payload.get("data") if isinstance(payload, dict) else None - if any(isinstance(e, dict) and e.get("type") in TERMINAL_EVENTS for e in events or []): - return False - if time.monotonic() >= deadline: - return True - await asyncio.sleep(self.poll_interval) + 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 @staticmethod - def _pick_new_campaign(campaigns: List[Dict[str, Any]], known_ids: set, target: str) -> Optional[Dict[str, Any]]: - """Find the campaign minted by the run. Only campaigns that did not exist before it are considered.""" - fresh = [c for c in campaigns if c["id"] not in known_ids] - if not fresh: - return None - fresh.sort(key=lambda c: str(c.get("date") or ""), reverse=True) - needle = target.lower() - return next((c for c in fresh if needle in str(c["id"]).lower()), fresh[0]) + def _raise_if_failed(terminal: Dict[str, Any], run_id: str) -> None: + reason = _failure_of(terminal) + if reason: + raise DarkmoonRunError(f"Darkmoon run {run_id} failed: {reason}.") + + async def _campaign_of_run( + self, session: aiohttp.ClientSession, token: str, run_id: str, events: List[Any] + ) -> Dict[str, Any]: + """The one campaign created by this run, matched through the run's own opencode session id. + + A campaign belongs to a run when its 8-char session_id is a window of the run's parent session id. This is + the same rule the Dashboard API uses to finalise a run's campaign. Newest-campaign guessing is never used. + """ + session_id = _first_session_id(events) + if not session_id: + raise DarkmoonCorrelationError(f"Run {run_id} finished but its log has no session id to correlate a campaign.") + matches = [c for c in await self._list_campaigns(session, token) if _owns_session(c, session_id)] + if len(matches) != 1: + raise DarkmoonCorrelationError( + f"Run {run_id} finished but {len(matches)} campaigns match its session; refusing to guess. " + "Use 'list_campaigns' and 'get_findings'." + ) + return matches[0] + + @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: - target = (params.target or "").strip() - body: Dict[str, Any] = {"target": target} - if params.program and params.program.strip(): - body["program"] = params.program.strip() - if params.focus: - body["focus"] = [f.strip() for f in params.focus if f.strip()] - if params.min_severity: - body["severity"] = params.min_severity - - known_ids = {c["id"] for c in await self._list_campaigns(session, token)} - handle = await self._request(session, "POST", "/api/v1/run/campaign", body, token) - run_id = handle.get("run_id") if isinstance(handle, dict) else None - if not run_id: - raise RuntimeError("Darkmoon did not return a run id.") - result = DarkmoonPentestToolOutputSchema(action=params.action, run_id=str(run_id)) + 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) if not params.wait_for_completion: return result - result.timed_out = await self._wait_for_run(session, token, str(run_id)) - campaign = self._pick_new_campaign(await self._list_campaigns(session, token), known_ids, target) - if campaign: - result.campaign_id = str(campaign["id"]) - findings = await self._get_findings(session, token, result.campaign_id) - result.total, result.stats, result.findings = findings["total"], findings["stats"], findings["findings"] + result.timed_out, events = await self._wait_for_run(session, token, run_id) + if result.timed_out: + return result + campaign = await self._campaign_of_run(session, token, run_id, events) + result.campaign_id = str(campaign["id"]) + findings = await self._get_findings(session, token, result.campaign_id) + result.total, result.stats, result.findings = findings["total"], findings["stats"], findings["findings"] 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 == "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 - ], - ) - if params.action == "get_findings": - 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) - return await self._run_pentest(session, token, params) + 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: From f0476a1d61ad07c294a3cb45a6c4eb91f19c5dee Mon Sep 17 00:00:00 2001 From: MBK-fr <41642346+MBK-fr@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:00:19 +0200 Subject: [PATCH 13/15] darkmoon_pentest: second review round (redirects, secret scrubbing, stats/version validation, verifiable campaign mapping) - Reject cross-origin credential redirects: resolve redirects by hand (allow_redirects=False) and refuse any 3xx whose Location leaves base_url's origin, so a 307 can never replay the login POST body or bearer token to another host. Same-origin redirects are followed, bounded to a few hops; missing Location and redirect loops raise DarkmoonRedirectError. - Scrub secrets from exception text, not just repr: the password and the login bearer token are redacted from every raised DarkmoonAPIError / DarkmoonRunError message, so a verbose or hostile API detail or run-error text cannot leak them. - Validate documented findings counts/stats: total must be a non-negative integer and not below the returned count; each stats bucket must map to non-negative integer counts. total=-9 and stats.by_severity='broken' now raise. - Reject present-but-invalid/incompatible version contracts: a present /system/info (HTTP 200) must be an object with api_version major 1 AND contract_version "1". Garbage api_version, missing fields and contract_version=2 fail closed; the unversioned fallback is only for an absent route (404). - Verifiable campaign id<->session mapping: a campaign is eligible only when its session_id is 8 lowercase hex chars AND the trailing segment of its 'camp__' id, matched as an 8-char window of the run's parent session id. A bare substring coincidence is no longer sufficient. Tests: +34 cases (98 total), 100% line coverage of tool/, black + flake8 (repo config and --max-complexity=5) clean, mypy clean. README documents the version contract, secret/redirect safety and the campaign mapping. --- atomic-forge/tools/darkmoon_pentest/README.md | 18 +- .../tests/test_darkmoon_pentest.py | 304 +++++++++++++++++- .../darkmoon_pentest/tool/darkmoon_pentest.py | 175 ++++++++-- 3 files changed, 464 insertions(+), 33 deletions(-) diff --git a/atomic-forge/tools/darkmoon_pentest/README.md b/atomic-forge/tools/darkmoon_pentest/README.md index 1e99e262..b3b893c3 100644 --- a/atomic-forge/tools/darkmoon_pentest/README.md +++ b/atomic-forge/tools/darkmoon_pentest/README.md @@ -7,7 +7,7 @@ The tool logs in with `POST /api/v1/auth/login`, then uses the returned token fo **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 refuses to run against any other major version; an instance that does not expose `/system/info` is treated as unversioned and accepted. 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). +**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. @@ -45,15 +45,25 @@ Only run assessments against systems you own or are explicitly authorised to tes - `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`) -`POST /api/v1/run/campaign` returns only a `run_id`, so the tool correlates the run to its campaign through the run's own opencode session id: the first `sessionID` in the run log (`GET /api/v1/run/logs/{run_id}`) must contain the campaign's `session_id`, which is the rule the Dashboard API itself uses to finalise a run's campaign. The newest or most recent campaign is never used. If zero or more than one campaign matches, or the log carries no session id, the tool raises `DarkmoonCorrelationError` and you can fall back to `list_campaigns` and `get_findings`. +#### Campaign correlation contract + +`POST /api/v1/run/campaign` returns only a `run_id`, so the tool correlates the run to its campaign through a verifiable id mapping rather than recency. Darkmoon creates a campaign id as `camp__` where `session_id = uuid4().hex[:8]` (see `api/live_push.py`), so for any well-formed campaign: + +- `session_id` is exactly 8 lowercase hex characters (`[0-9a-f]{8}`), and +- `session_id` is the trailing segment of `id` (i.e. `id` matches `^camp_\d{8}_$`). + +A finished run is attributed to the one campaign whose **verified** `session_id` token is one of the 8-character windows of the run's normalised parent session id (the first `sessionID` in `GET /api/v1/run/logs/{run_id}`, with `ses_` and `-` stripped). This is the same window rule the Dashboard API uses to finalise a run's campaign (`api/live_push.py`, `api/routes_run.py`), tightened so a campaign is only eligible when the window token is **also** the token encoded in its id — a bare 8-character substring coincidence is not sufficient. The newest or most recent campaign is never used. If zero or more than one campaign matches, or the log carries no session id, the tool raises `DarkmoonCorrelationError` and you can fall back to `list_campaigns` and `get_findings`. This mapping is unit-tested in `tests/test_darkmoon_pentest.py` (`test_campaign_session_token_*`, `test_owns_session_requires_window_and_id_consistency`, `test_campaign_whose_id_does_not_encode_its_session_token_is_not_attributed`). Failure 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, an unsupported API major version). +- `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` or `stats` count, or a present-but-incompatible/malformed version contract). - `DarkmoonCorrelationError`: see above. +- `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. If the wait times out, `timed_out` is `True`, no findings are returned, and the run keeps going on the Darkmoon side (read it later with `list_campaigns` and `get_findings`). -`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. If the wait times out, `timed_out` is `True`, no findings are returned, and the run keeps going on the Darkmoon side (read it later with `list_campaigns` and `get_findings`). TLS certificates are always verified, and `password` is excluded from the config `repr`. +**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. diff --git a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py index 7d3becbf..a0d348bd 100644 --- a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py @@ -26,8 +26,12 @@ DarkmoonPentestToolConfig, DarkmoonPentestToolInputSchema, DarkmoonPentestToolOutputSchema, + DarkmoonRedirectError, DarkmoonRunError, DarkmoonSchemaError, + _campaign_session_token, + _owns_session, + _session_windows, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -294,9 +298,12 @@ async def test_malformed_findings_are_an_error_not_zero_findings(darkmoon, body) @pytest.mark.asyncio async def test_login_without_token_is_a_schema_error(darkmoon, monkeypatch): _, tool = darkmoon + original = tool._request - async def no_token(*args, **kwargs): - return {"user": "analyst"} + 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"): @@ -422,7 +429,10 @@ async def test_finished_run_with_no_matching_campaign_is_an_error(darkmoon): @pytest.mark.asyncio async def test_finished_run_with_two_matching_campaigns_is_an_error(darkmoon): fake, tool = darkmoon - fake.campaigns["data"].append({"id": "camp_20261004_twin", "session_id": "5f3a9c1e"}) + # A second, fully contract-conformant campaign that encodes the same session token. + fake.campaigns["data"].append( + {"id": "camp_20260101_5f3a9c1e", "session_id": "5f3a9c1e", "date": "2026-01-01T00:00:00+00:00"} + ) with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): await tool.run_async(RUN_INPUT) assert fake.count("vulnerabilities") == 0 @@ -572,3 +582,291 @@ async def test_self_signed_certificate_is_rejected(tmp_path): 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 + + +################################################# +# Verifiable campaign id <-> session (ask 5) # +################################################# +def test_campaign_session_token_contract(): + # Well-formed: 8 lowercase hex chars that are the trailing segment of 'camp__'. + assert _campaign_session_token({"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e"}) == "5f3a9c1e" + + +@pytest.mark.parametrize( + "campaign", + [ + {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1E"}, # uppercase is not uuid hex[:8] + {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1"}, # 7 chars + {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1ee"}, # 9 chars + {"id": "camp_20261004_deadbeef", "session_id": "5f3a9c1e"}, # token not encoded in the id + {"id": "report_5f3a9c1e", "session_id": "5f3a9c1e"}, # id not camp__ + {"id": "camp_2026104_5f3a9c1e", "session_id": "5f3a9c1e"}, # malformed date segment + {"id": "camp_20261004_5f3a9c1e"}, # no session_id + {"session_id": "5f3a9c1e"}, # no id + ], +) +def test_campaign_session_token_rejects_nonconformant(campaign): + assert _campaign_session_token(campaign) is None + + +def test_owns_session_requires_window_and_id_consistency(): + windows = _session_windows("2a9e5f3a9c1eabcdef0123") # normalised opencode parent session id + assert "5f3a9c1e" in windows + # Verified token that is a window of this run's session → owned. + assert _owns_session({"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e"}, windows) is True + # Correct token position, but the id encodes a different token → not a bare-substring match. + assert _owns_session({"id": "camp_20261004_deadbeef", "session_id": "5f3a9c1e"}, windows) is False + # Conformant record whose token is not a window of this run's session. + assert _owns_session({"id": "camp_20261004_99999999", "session_id": "99999999"}, windows) is False + + +@pytest.mark.asyncio +async def test_campaign_whose_id_does_not_encode_its_session_token_is_not_attributed(darkmoon): + """A substring-only match is no longer enough: the id must encode the same token.""" + fake, tool = darkmoon + fake.campaigns["data"] = [{"id": "camp_20261004_deadbeef", "session_id": "5f3a9c1e", "date": "2026-10-04T10:15:30+00:00"}] + with pytest.raises(DarkmoonCorrelationError): + await tool.run_async(RUN_INPUT) + assert fake.count("vulnerabilities") == 0 diff --git a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py index f84eabcd..a962c3dc 100644 --- a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py @@ -1,9 +1,10 @@ import asyncio import os +import re import time from concurrent.futures import ThreadPoolExecutor -from typing import Any, Dict, List, Literal, Optional, Tuple -from urllib.parse import quote, urlsplit +from typing import Any, Dict, List, Literal, Optional, Set, Tuple +from urllib.parse import quote, urljoin, urlsplit import aiohttp from pydantic import Field, ValidationError, model_validator @@ -13,8 +14,18 @@ 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") + +# Verifiable campaign <-> run session mapping (see _campaign_session_token). +# Darkmoon creates a campaign id as "camp__" where +# session_id = uuid4().hex[:8] (api/live_push.py), so a well-formed campaign's +# session token is 8 lowercase hex chars AND the trailing segment of its id. +CAMPAIGN_ID_RE = re.compile(r"^camp_\d{8}_([0-9a-f]{8})$") def _is_blank(value: Optional[str]) -> bool: @@ -41,6 +52,10 @@ class DarkmoonCorrelationError(RuntimeError): """A finished run could not be attributed to exactly one campaign, so no findings are returned for it.""" +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 # ################ @@ -154,10 +169,36 @@ def _error_message(payload: Any, reason: Optional[str]) -> str: return detail if isinstance(detail, str) and detail else str(reason) -def _owns_session(campaign: Dict[str, Any], session_id: str) -> bool: - """True when the campaign's 8-char session_id is a window of the run's parent session id.""" - fragment = campaign.get("session_id") - return isinstance(fragment, str) and len(fragment) >= 8 and fragment in session_id +def _campaign_session_token(campaign: Dict[str, Any]) -> Optional[str]: + """The campaign's verified 8-hex session token, or None if the record is not contract-conformant. + + Darkmoon derives a campaign id as 'camp__' with + session_id = uuid4().hex[:8], so a well-formed record's session_id is exactly 8 + lowercase hex characters AND is the trailing segment of its id. A record that + satisfies only one half (e.g. an arbitrary 8-char substring that is not encoded + in the id) is NOT eligible for correlation. + """ + token = campaign.get("session_id") + match = CAMPAIGN_ID_RE.fullmatch(str(campaign.get("id", ""))) + if not (isinstance(token, str) and match and match.group(1) == token): + return None + return token + + +def _session_windows(session_id: str) -> Set[str]: + """Every 8-char window of the run's normalised parent session id.""" + return {session_id[i : i + 8] for i in range(len(session_id) - 7)} + + +def _owns_session(campaign: Dict[str, Any], windows: Set[str]) -> bool: + """True when the campaign's verified session token is one of the run session's 8-char windows. + + This is the rule the Dashboard API itself uses to finalise a run's campaign + (api/live_push.py / api/routes_run.py), tightened here so the token must also be + the one encoded in the campaign id, never a bare substring coincidence. + """ + token = _campaign_session_token(campaign) + return token is not None and token in windows def _clean_focus(focus: Optional[List[str]]) -> List[str]: @@ -201,13 +242,37 @@ def _parse_finding(item: Any) -> DarkmoonFinding: 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) -> 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(): + _require_count(count, f"stats.{key}.{name}") + + +def _require_stats(stats: Any) -> 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: + bucket = stats.get(key) + if bucket is not None: + _require_stat_bucket(key, bucket) + return stats + + def _parse_findings(payload: Any) -> Dict[str, Any]: body = _require_dict(payload, "vulnerabilities") findings = [_parse_finding(item) for item in _require_list(body, "data", "vulnerabilities")] - total = body.get("total", len(findings)) - stats = body.get("stats", {}) - if isinstance(total, bool) or not isinstance(total, int) or not isinstance(stats, dict): - raise DarkmoonSchemaError("Unexpected Darkmoon response for vulnerabilities: bad 'total' or 'stats'.") + 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", {})) return {"total": total, "stats": stats, "findings": findings} @@ -234,13 +299,37 @@ 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) -> None: - version = info.get("api_version") if isinstance(info, dict) else None - major = str(version or "").split(".")[0].lstrip("vV") - if major.isdigit() and int(major) != SUPPORTED_API_MAJOR: + """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. + """ + data = _require_dict(info, "system info") + if _major_of(data.get("api_version")) != SUPPORTED_API_MAJOR: raise DarkmoonSchemaError( - f"Unsupported Darkmoon Dashboard API version {version}; this tool supports major {SUPPORTED_API_MAJOR}." + 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( + 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) ##################### @@ -257,6 +346,16 @@ def __init__(self, config: DarkmoonPentestToolConfig = DarkmoonPentestToolConfig 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})" @@ -290,16 +389,37 @@ async def _request( ) -> Any: headers = {"Authorization": f"Bearer {token}"} if token else {} limit = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) - async with session.request(method, f"{self.base_url}{path}", json=body, headers=headers, timeout=limit) as resp: - payload = await _read_json(resp) - if resp.status >= 400: - raise DarkmoonAPIError(resp.status, _error_message(payload, resp.reason)) - return payload + url = f"{self.base_url}{path}" + # Redirects are resolved by hand (allow_redirects=False) so a credentialed + # request is never replayed cross-origin by the HTTP client. + for _ in range(MAX_REDIRECTS + 1): + async with session.request(method, url, json=body, headers=headers, timeout=limit, allow_redirects=False) as resp: + if resp.status in REDIRECT_STATUSES: + url = self._same_origin_target(url, resp.headers.get("Location")) + continue + payload = await _read_json(resp) + if resp.status >= 400: + raise DarkmoonAPIError(resp.status, self._scrub(_error_message(payload, resp.reason))) + return payload + raise DarkmoonRedirectError(f"Darkmoon API exceeded {MAX_REDIRECTS} redirects for {path}.") + + 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) - return _require_str(_require_dict(payload, "login"), "token", "login") + 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.""" @@ -359,24 +479,27 @@ async def _wait_for_run(self, session: aiohttp.ClientSession, token: str, run_id await asyncio.sleep(min(self.poll_interval, max(deadline - time.monotonic(), 0))) return True, events - @staticmethod - def _raise_if_failed(terminal: Dict[str, Any], run_id: str) -> None: + def _raise_if_failed(self, terminal: Dict[str, Any], run_id: str) -> None: reason = _failure_of(terminal) if reason: - raise DarkmoonRunError(f"Darkmoon run {run_id} failed: {reason}.") + raise DarkmoonRunError(self._scrub(f"Darkmoon run {run_id} failed: {reason}.")) async def _campaign_of_run( self, session: aiohttp.ClientSession, token: str, run_id: str, events: List[Any] ) -> Dict[str, Any]: """The one campaign created by this run, matched through the run's own opencode session id. - A campaign belongs to a run when its 8-char session_id is a window of the run's parent session id. This is - the same rule the Dashboard API uses to finalise a run's campaign. Newest-campaign guessing is never used. + A campaign belongs to a run when its verified session token (8 lowercase hex chars that are also the + trailing segment of its 'camp__' id) is one of the 8-char windows of the run's parent + session id. This is the rule the Dashboard API uses to finalise a run's campaign, tightened so the token + must be the one encoded in the campaign id rather than a bare substring. Newest-campaign guessing is never + used, and zero or more than one match raises DarkmoonCorrelationError. """ session_id = _first_session_id(events) if not session_id: raise DarkmoonCorrelationError(f"Run {run_id} finished but its log has no session id to correlate a campaign.") - matches = [c for c in await self._list_campaigns(session, token) if _owns_session(c, session_id)] + windows = _session_windows(session_id) + matches = [c for c in await self._list_campaigns(session, token) if _owns_session(c, windows)] if len(matches) != 1: raise DarkmoonCorrelationError( f"Run {run_id} finished but {len(matches)} campaigns match its session; refusing to guess. " From aef008564c61f50dbb3f234736c0c9d5c6f78ebd Mon Sep 17 00:00:00 2001 From: MBK-fr <41642346+MBK-fr@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:48:59 +0200 Subject: [PATCH 14/15] darkmoon_pentest: third review round (split _request below CRAP 6; narrow campaign-correlation claim honestly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ask 1 — _request complexity. Native Radon 6.0.1 scored DarkmoonPentestTool._request at cyclomatic 6 (B), failing the strictly-below-6 CRAP bar. The redirect-resolution flow is split at meaningful boundaries: - _send_once(): one request with allow_redirects=False, returning (next_url, payload) — a same-origin redirect target still to follow, or None with the final body. CC 3 (A). - _auth_headers() / _request_timeout(): the two header/timeout ternaries, CC 2 (A) each. - _request(): now just the bounded redirect loop driving _send_once. CC 3 (A). Behaviour is identical: cross-origin credentialed redirects are still refused, same-origin redirects followed and bounded to MAX_REDIRECTS, missing Location and loops still raise. radon cc -s now reports every production and test function at grade A (<6). Ask 2 — campaign ownership evidence (honest narrowing, not a stronger claim). The public contract exposes no unique run->campaign key: the campaign record carries no run_id, owner or full session id (api/live_push.py), its session_id is the independent MCP SESSION_ID = uuid4().hex[:8] (mcp/src/server.py), and POST /run/campaign returns only {run_id, pid, command} (api/routes_run.py). The only linkage the server itself uses is a substring/window match of the campaign token against the run's parent session id, restricted to non-subagent campaigns (_finalize_stale_campaigns), which is a best-effort reconciliation, not authoritative concurrent attribution. Rather than overstate this: - _owns_session() now also excludes is_subagent campaigns, mirroring the server rule and reducing false matches; the id<->token invariant stays. - docstrings, the module header and the README state exactly what the check guarantees (verifiable id<->token conformance + the server's own correlation rule, fail-safe refusal on zero/ambiguous matches) and what it does NOT (robust concurrent-run attribution; no unique key exists in the contract). - the tool stays conservative: zero OR more than one eligible campaign raises DarkmoonCorrelationError instead of returning possibly-wrong findings. Tests: +3 cases (101 total) — sub-agent exclusion and a two-concurrent-campaign ambiguity that must refuse rather than guess. 100% line coverage of tool/, black (line-length 127) and flake8 (repo config and --max-complexity=5) clean, mypy clean on the tool. --- atomic-forge/tools/darkmoon_pentest/README.md | 10 +- .../tests/test_darkmoon_pentest.py | 42 +++++++ .../darkmoon_pentest/tool/darkmoon_pentest.py | 105 +++++++++++++----- 3 files changed, 128 insertions(+), 29 deletions(-) diff --git a/atomic-forge/tools/darkmoon_pentest/README.md b/atomic-forge/tools/darkmoon_pentest/README.md index b3b893c3..711c3223 100644 --- a/atomic-forge/tools/darkmoon_pentest/README.md +++ b/atomic-forge/tools/darkmoon_pentest/README.md @@ -47,12 +47,18 @@ Only run assessments against systems you own or are explicitly authorised to tes #### Campaign correlation contract -`POST /api/v1/run/campaign` returns only a `run_id`, so the tool correlates the run to its campaign through a verifiable id mapping rather than recency. Darkmoon creates a campaign id as `camp__` where `session_id = uuid4().hex[:8]` (see `api/live_push.py`), so for any well-formed campaign: +`POST /api/v1/run/campaign` returns only `{run_id, pid, command}` (see `api/routes_run.py`), with no campaign id, so after a run finishes the tool has to find the campaign it produced. + +**What the contract gives us (verifiable offline).** Darkmoon creates a campaign id as `camp__`, where `session_id` is the dashboard MCP server's `SESSION_ID = uuid4().hex[:8]` (`mcp/src/server.py` → `api/live_push.py`). So for any well-formed campaign: - `session_id` is exactly 8 lowercase hex characters (`[0-9a-f]{8}`), and - `session_id` is the trailing segment of `id` (i.e. `id` matches `^camp_\d{8}_$`). -A finished run is attributed to the one campaign whose **verified** `session_id` token is one of the 8-character windows of the run's normalised parent session id (the first `sessionID` in `GET /api/v1/run/logs/{run_id}`, with `ses_` and `-` stripped). This is the same window rule the Dashboard API uses to finalise a run's campaign (`api/live_push.py`, `api/routes_run.py`), tightened so a campaign is only eligible when the window token is **also** the token encoded in its id — a bare 8-character substring coincidence is not sufficient. The newest or most recent campaign is never used. If zero or more than one campaign matches, or the log carries no session id, the tool raises `DarkmoonCorrelationError` and you can fall back to `list_campaigns` and `get_findings`. This mapping is unit-tested in `tests/test_darkmoon_pentest.py` (`test_campaign_session_token_*`, `test_owns_session_requires_window_and_id_consistency`, `test_campaign_whose_id_does_not_encode_its_session_token_is_not_attributed`). +A finished run is attributed to the one **non-subagent** campaign whose **verified** `session_id` token is one of the 8-character windows of the run's normalised parent session id (the first `sessionID` in `GET /api/v1/run/logs/{run_id}`, with `ses_` and `-` stripped). This mirrors the server's own correlation rule (`_finalize_stale_campaigns` in `api/routes_run.py` matches a campaign's 8-char session token as a substring of the run's parent session id and skips sub-agent campaigns), tightened here so a campaign is only eligible when the window token is **also** the token encoded in its id — a bare 8-character substring coincidence is not sufficient. The newest or most recent campaign is never used. + +**What it does NOT establish, and how the tool stays safe.** This is a correlation, not an authoritative ownership proof. The contract exposes **no unique run→campaign key**: the campaign record carries no `run_id`, no owner, and no full session id (its `session_id` is the independent 8-char MCP `SESSION_ID`, not derived from the opencode session id that appears in the run log), and the run-start response carries no campaign id. So this check reliably rejects *missing* and *non-conformant* matches and reduces false ones, but it cannot by itself guarantee correct attribution when two runs are genuinely concurrent and their sessions overlap. Because of that, the tool is deliberately conservative and **fails safe**: if zero **or more than one** eligible campaign matches, or the log carries no session id, it raises `DarkmoonCorrelationError` instead of returning possibly-wrong findings, and you fall back to `list_campaigns` and `get_findings` to pick the campaign explicitly. + +The id↔token invariant, the sub-agent exclusion, and the fail-safe behaviour are unit-tested in `tests/test_darkmoon_pentest.py` (`test_campaign_session_token_*`, `test_owns_session_requires_window_and_id_consistency`, `test_campaign_whose_id_does_not_encode_its_session_token_is_not_attributed`, `test_subagent_campaign_is_never_attributed`, `test_concurrent_ambiguous_campaigns_refuse_rather_than_guess`). The fixtures mirror the id/`session_id` derivation in the Dashboard source; they are not recordings of a live instance, so they verify the *shape and invariant* of the mapping, not a live concurrent reproduction. Failure 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. diff --git a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py index a0d348bd..4d838a38 100644 --- a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py @@ -870,3 +870,45 @@ async def test_campaign_whose_id_does_not_encode_its_session_token_is_not_attrib with pytest.raises(DarkmoonCorrelationError): await tool.run_async(RUN_INPUT) assert fake.count("vulnerabilities") == 0 + + +def test_subagent_campaign_is_never_attributed_helper(): + """A sub-agent campaign is excluded even when its verified token is a window of the run session. + + The server's own correlation (_finalize_stale_campaigns) skips `is_subagent` campaigns, so the + tool mirrors that: a sub-agent campaign merges into its parent and must never own a run's findings. + """ + windows = _session_windows("2a9e5f3a9c1eabcdef0123") + conformant = {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e"} + assert _owns_session(conformant, windows) is True + assert _owns_session(conformant | {"is_subagent": True}, windows) is False + + +@pytest.mark.asyncio +async def test_subagent_campaign_is_never_attributed(darkmoon): + fake, tool = darkmoon + fake.campaigns["data"] = [ + {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e", "is_subagent": True, "date": "2026-10-04T10:15:30+00:00"} + ] + with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): + await tool.run_async(RUN_INPUT) + assert fake.count("vulnerabilities") == 0 + + +@pytest.mark.asyncio +async def test_concurrent_ambiguous_campaigns_refuse_rather_than_guess(darkmoon): + """Two concurrent runs whose non-subagent campaigns both encode a token present in this run's + session windows cannot be disambiguated from the contract (no run_id/owner on the campaign record). + The tool must fail safe and refuse, never return one campaign's findings as if it owned the run. + + The run's parent session id is `ses_2a9e5f3a9c1eAbCdEf0123`; both `5f3a9c1e` and `2a9e5f3a` + are 8-char lowercase-hex windows of its normalised form, so both conformant campaigns are eligible. + """ + fake, tool = darkmoon + fake.campaigns["data"] = [ + {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e", "is_subagent": False, "date": "2026-10-04T10:15:30+00:00"}, + {"id": "camp_20261004_2a9e5f3a", "session_id": "2a9e5f3a", "is_subagent": False, "date": "2026-10-04T10:16:00+00:00"}, + ] + with pytest.raises(DarkmoonCorrelationError, match="2 campaigns match"): + await tool.run_async(RUN_INPUT) + assert fake.count("vulnerabilities") == 0 diff --git a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py index a962c3dc..fac71c10 100644 --- a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py @@ -21,10 +21,24 @@ MAX_REDIRECTS = 5 STATS_BUCKETS = ("by_severity", "by_category", "by_status") -# Verifiable campaign <-> run session mapping (see _campaign_session_token). -# Darkmoon creates a campaign id as "camp__" where -# session_id = uuid4().hex[:8] (api/live_push.py), so a well-formed campaign's -# session token is 8 lowercase hex chars AND the trailing segment of its id. +# Campaign <-> run session mapping (see _campaign_session_token / _owns_session). +# +# Darkmoon creates a campaign id as "camp__" where session_id +# is the dashboard MCP server's SESSION_ID = uuid4().hex[:8] (mcp/src/server.py -> +# api/live_push.py), so a well-formed campaign's session token is 8 lowercase hex +# chars AND the trailing segment of its id. We can verify that invariant offline. +# +# HONEST LIMITATION: that invariant is all the public contract gives us. A campaign +# record carries no run_id, no owner and no full session id (see api/live_push.py: +# the campaign dict has id/project_id/target_id/session_id/is_subagent/status/...), +# and POST /run/campaign returns only {run_id, pid, command} (api/routes_run.py), so +# there is NO unique run->campaign key to read. The only linkage the server itself +# uses is a substring/window match of the campaign's 8-char session token against the +# run's parent session id, restricted to non-subagent campaigns +# (_finalize_stale_campaigns in api/routes_run.py), which is a best-effort +# reconciliation sweep, not authoritative concurrent-run attribution. We therefore +# mirror that same rule and FAIL SAFE: zero or more than one eligible match raises +# rather than guessing. See the README "Campaign correlation contract" section. CAMPAIGN_ID_RE = re.compile(r"^camp_\d{8}_([0-9a-f]{8})$") @@ -191,12 +205,18 @@ def _session_windows(session_id: str) -> Set[str]: def _owns_session(campaign: Dict[str, Any], windows: Set[str]) -> bool: - """True when the campaign's verified session token is one of the run session's 8-char windows. - - This is the rule the Dashboard API itself uses to finalise a run's campaign - (api/live_push.py / api/routes_run.py), tightened here so the token must also be - the one encoded in the campaign id, never a bare substring coincidence. + """True when a non-subagent campaign's verified session token is one of the run session's windows. + + Mirrors the server's own correlation rule (_finalize_stale_campaigns in + api/routes_run.py: a campaign's 8-char session token is a substring of the run's + parent session id, and sub-agent campaigns are skipped), tightened here so the + token must also be the one encoded in the campaign id, never a bare substring + coincidence. This narrows false matches but does NOT by itself guarantee unique + concurrent-run attribution (the contract exposes no run->campaign key); the caller + fails safe when more than one campaign is eligible. """ + if campaign.get("is_subagent"): + return False token = _campaign_session_token(campaign) return token is not None and token in windows @@ -378,6 +398,12 @@ def _check_settings(self) -> None: 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 _request_timeout(self, timeout: Optional[float]) -> aiohttp.ClientTimeout: + return aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) + async def _request( self, session: aiohttp.ClientSession, @@ -387,22 +413,42 @@ async def _request( token: Optional[str] = None, timeout: Optional[float] = None, ) -> Any: - headers = {"Authorization": f"Bearer {token}"} if token else {} - limit = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) + """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.""" + headers = self._auth_headers(token) + limit = self._request_timeout(timeout) url = f"{self.base_url}{path}" - # Redirects are resolved by hand (allow_redirects=False) so a credentialed - # request is never replayed cross-origin by the HTTP client. for _ in range(MAX_REDIRECTS + 1): - async with session.request(method, url, json=body, headers=headers, timeout=limit, allow_redirects=False) as resp: - if resp.status in REDIRECT_STATUSES: - url = self._same_origin_target(url, resp.headers.get("Location")) - continue - payload = await _read_json(resp) - if resp.status >= 400: - raise DarkmoonAPIError(resp.status, self._scrub(_error_message(payload, resp.reason))) + next_url, payload = await self._send_once(session, method, url, body, headers, limit) + if next_url is None: 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: @@ -487,13 +533,18 @@ def _raise_if_failed(self, terminal: Dict[str, Any], run_id: str) -> None: async def _campaign_of_run( self, session: aiohttp.ClientSession, token: str, run_id: str, events: List[Any] ) -> Dict[str, Any]: - """The one campaign created by this run, matched through the run's own opencode session id. - - A campaign belongs to a run when its verified session token (8 lowercase hex chars that are also the - trailing segment of its 'camp__' id) is one of the 8-char windows of the run's parent - session id. This is the rule the Dashboard API uses to finalise a run's campaign, tightened so the token - must be the one encoded in the campaign id rather than a bare substring. Newest-campaign guessing is never - used, and zero or more than one match raises DarkmoonCorrelationError. + """The one campaign this run can be safely attributed to, matched through the run's opencode session id. + + A non-subagent campaign is eligible when its verified session token (8 lowercase hex chars that are also + the trailing segment of its 'camp__' id) is one of the 8-char windows of the run's parent + session id. This mirrors the server's own correlation rule (_finalize_stale_campaigns), tightened so the + token must be the one encoded in the campaign id rather than a bare substring. Newest-campaign guessing is + never used. + + The contract exposes no unique run->campaign key (no run_id/owner/full session id on the campaign record), + so this correlation cannot by itself guarantee attribution across truly concurrent runs. The function is + therefore conservative: zero OR more than one eligible campaign raises DarkmoonCorrelationError instead of + returning a possibly-wrong campaign's findings. """ session_id = _first_session_id(events) if not session_id: From 4fa089656fe2dfc2aa9e0d6c3a0daae785039e59 Mon Sep 17 00:00:00 2001 From: MBK-fr <41642346+MBK-fr@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:07:38 +0200 Subject: [PATCH 15/15] darkmoon_pentest: fourth review round Stop unsound findings auto-attribution; apply the whole-run redirect budget to every hop and the final result; scrub version values and stats bucket names in error messages; reject explicitly-null stats buckets. - run_pentest no longer attributes findings to the run it starts. The public contract exposes no verifiable run->campaign key (the campaign record in mcp/api/live_push.py has no run_id/owner, SESSION_ID is an independent uuid4().hex[:8] in mcp/src/server.py, and the run-start response has no campaign id), so a session-id suffix/window match could pair a run with an unrelated campaign and return the wrong findings. run_pentest now reports run_status (started/completed/ timed_out); findings are read explicitly via list_campaigns + get_findings. Removed the correlation helpers, DarkmoonCorrelationError and the "returns its findings"/"fails safe" promises in docstrings and README. - _request tracks one whole-request time budget: every redirect hop gets only the time left, and the deadline is rechecked before a terminal result is accepted, so chained hops cannot overrun run_timeout. - Version values and stats bucket names are routed through _scrub before being interpolated into DarkmoonSchemaError messages. - A present stats bucket (including an explicit null) must be a valid object; absent buckets stay optional. radon cc all grade A (<6); 94 tests pass with 100% line and branch coverage of tool/; black (127), flake8 (repo config and --max-complexity=5) and mypy clean. --- atomic-forge/tools/darkmoon_pentest/README.md | 26 +- .../tests/test_darkmoon_pentest.py | 243 ++++++++++-------- .../darkmoon_pentest/tool/darkmoon_pentest.py | 208 ++++++--------- 3 files changed, 216 insertions(+), 261 deletions(-) diff --git a/atomic-forge/tools/darkmoon_pentest/README.md b/atomic-forge/tools/darkmoon_pentest/README.md index 711c3223..da192fd1 100644 --- a/atomic-forge/tools/darkmoon_pentest/README.md +++ b/atomic-forge/tools/darkmoon_pentest/README.md @@ -36,38 +36,30 @@ Only run assessments against systems you own or are explicitly authorised to tes - `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 and return its findings (default `True`). +- `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`, `campaign_id`, `timed_out` +- `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`) -#### Campaign correlation contract +#### Why findings are not auto-attributed to a run -`POST /api/v1/run/campaign` returns only `{run_id, pid, command}` (see `api/routes_run.py`), with no campaign id, so after a run finishes the tool has to find the campaign it produced. +`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. -**What the contract gives us (verifiable offline).** Darkmoon creates a campaign id as `camp__`, where `session_id` is the dashboard MCP server's `SESSION_ID = uuid4().hex[:8]` (`mcp/src/server.py` → `api/live_push.py`). So for any well-formed campaign: +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. -- `session_id` is exactly 8 lowercase hex characters (`[0-9a-f]{8}`), and -- `session_id` is the trailing segment of `id` (i.e. `id` matches `^camp_\d{8}_$`). +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. -A finished run is attributed to the one **non-subagent** campaign whose **verified** `session_id` token is one of the 8-character windows of the run's normalised parent session id (the first `sessionID` in `GET /api/v1/run/logs/{run_id}`, with `ses_` and `-` stripped). This mirrors the server's own correlation rule (`_finalize_stale_campaigns` in `api/routes_run.py` matches a campaign's 8-char session token as a substring of the run's parent session id and skips sub-agent campaigns), tightened here so a campaign is only eligible when the window token is **also** the token encoded in its id — a bare 8-character substring coincidence is not sufficient. The newest or most recent campaign is never used. - -**What it does NOT establish, and how the tool stays safe.** This is a correlation, not an authoritative ownership proof. The contract exposes **no unique run→campaign key**: the campaign record carries no `run_id`, no owner, and no full session id (its `session_id` is the independent 8-char MCP `SESSION_ID`, not derived from the opencode session id that appears in the run log), and the run-start response carries no campaign id. So this check reliably rejects *missing* and *non-conformant* matches and reduces false ones, but it cannot by itself guarantee correct attribution when two runs are genuinely concurrent and their sessions overlap. Because of that, the tool is deliberately conservative and **fails safe**: if zero **or more than one** eligible campaign matches, or the log carries no session id, it raises `DarkmoonCorrelationError` instead of returning possibly-wrong findings, and you fall back to `list_campaigns` and `get_findings` to pick the campaign explicitly. - -The id↔token invariant, the sub-agent exclusion, and the fail-safe behaviour are unit-tested in `tests/test_darkmoon_pentest.py` (`test_campaign_session_token_*`, `test_owns_session_requires_window_and_id_consistency`, `test_campaign_whose_id_does_not_encode_its_session_token_is_not_attributed`, `test_subagent_campaign_is_never_attributed`, `test_concurrent_ambiguous_campaigns_refuse_rather_than_guess`). The fixtures mirror the id/`session_id` derivation in the Dashboard source; they are not recordings of a live instance, so they verify the *shape and invariant* of the mapping, not a live concurrent reproduction. - -Failure handling, all raised as exceptions rather than returned 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` or `stats` count, or a present-but-incompatible/malformed version contract). -- `DarkmoonCorrelationError`: see above. +- `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. If the wait times out, `timed_out` is `True`, no findings are returned, and the run keeps going on the Darkmoon side (read it later with `list_campaigns` and `get_findings`). +`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. diff --git a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py index 4d838a38..a7877c4f 100644 --- a/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tests/test_darkmoon_pentest.py @@ -20,7 +20,6 @@ from tool.darkmoon_pentest import ( # noqa: E402 DarkmoonAPIError, - DarkmoonCorrelationError, DarkmoonFinding, DarkmoonPentestTool, DarkmoonPentestToolConfig, @@ -29,9 +28,7 @@ DarkmoonRedirectError, DarkmoonRunError, DarkmoonSchemaError, - _campaign_session_token, - _owns_session, - _session_windows, + STATS_BUCKETS, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -369,7 +366,7 @@ async def failing(session, method, path, *args, **kwargs): # run_pentest # ################ @pytest.mark.asyncio -async def test_run_pentest_sends_body_and_returns_the_runs_own_campaign(darkmoon): +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" @@ -381,20 +378,40 @@ async def test_run_pentest_sends_body_and_returns_the_runs_own_campaign(darkmoon "focus": ["auth", "injection"], "severity": "medium", } - assert (output.run_id, output.timed_out, output.campaign_id) == (RUN_ID, False, CAMPAIGN_ID) - assert output.total == 1 and output.findings[0].category == "sql_injection" - assert ("vulnerabilities", "camp_20260101_01d5e551") not in fake.requests + # 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_concurrent_run_campaign_is_never_attached_to_this_run(darkmoon): - """A newer campaign for the same target started by another run must not be picked up.""" +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 - decoy = {"id": "camp_20261004_99999999", "session_id": "99999999", "date": "2026-10-04T10:40:00+00:00"} - fake.campaigns["data"].append(decoy) + # 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.campaign_id == CAMPAIGN_ID - assert ("vulnerabilities", decoy["id"]) not in fake.requests + 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 @@ -402,7 +419,7 @@ 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 + 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 @@ -413,39 +430,10 @@ async def test_run_waits_through_a_missing_log_then_completes(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.campaign_id == CAMPAIGN_ID + assert output.run_status == "completed" and output.campaign_id is None assert fake.count("logs") == 3 -@pytest.mark.asyncio -async def test_finished_run_with_no_matching_campaign_is_an_error(darkmoon): - fake, tool = darkmoon - fake.campaigns["data"] = [c for c in fake.campaigns["data"] if c["id"] != CAMPAIGN_ID] - with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): - await tool.run_async(RUN_INPUT) - assert fake.count("vulnerabilities") == 0 - - -@pytest.mark.asyncio -async def test_finished_run_with_two_matching_campaigns_is_an_error(darkmoon): - fake, tool = darkmoon - # A second, fully contract-conformant campaign that encodes the same session token. - fake.campaigns["data"].append( - {"id": "camp_20260101_5f3a9c1e", "session_id": "5f3a9c1e", "date": "2026-01-01T00:00:00+00:00"} - ) - with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): - await tool.run_async(RUN_INPUT) - assert fake.count("vulnerabilities") == 0 - - -@pytest.mark.asyncio -async def test_run_log_without_session_id_is_an_error(darkmoon): - fake, tool = darkmoon - fake.log_replies = [logs_with({"type": "run_started"}, {"type": "run_completed", "exit_code": 0})] - with pytest.raises(DarkmoonCorrelationError, match="session id"): - await tool.run_async(RUN_INPUT) - - ################################ # Failed runs are failures # ################################ @@ -826,89 +814,118 @@ async def test_supported_contract_version_is_accepted(darkmoon): assert len(output.campaigns) == 2 -################################################# -# Verifiable campaign id <-> session (ask 5) # -################################################# -def test_campaign_session_token_contract(): - # Well-formed: 8 lowercase hex chars that are the trailing segment of 'camp__'. - assert _campaign_session_token({"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e"}) == "5f3a9c1e" +########################################## +# 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.parametrize( - "campaign", - [ - {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1E"}, # uppercase is not uuid hex[:8] - {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1"}, # 7 chars - {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1ee"}, # 9 chars - {"id": "camp_20261004_deadbeef", "session_id": "5f3a9c1e"}, # token not encoded in the id - {"id": "report_5f3a9c1e", "session_id": "5f3a9c1e"}, # id not camp__ - {"id": "camp_2026104_5f3a9c1e", "session_id": "5f3a9c1e"}, # malformed date segment - {"id": "camp_20261004_5f3a9c1e"}, # no session_id - {"session_id": "5f3a9c1e"}, # no id - ], -) -def test_campaign_session_token_rejects_nonconformant(campaign): - assert _campaign_session_token(campaign) is None +@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 -def test_owns_session_requires_window_and_id_consistency(): - windows = _session_windows("2a9e5f3a9c1eabcdef0123") # normalised opencode parent session id - assert "5f3a9c1e" in windows - # Verified token that is a window of this run's session → owned. - assert _owns_session({"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e"}, windows) is True - # Correct token position, but the id encodes a different token → not a bare-substring match. - assert _owns_session({"id": "camp_20261004_deadbeef", "session_id": "5f3a9c1e"}, windows) is False - # Conformant record whose token is not a window of this run's session. - assert _owns_session({"id": "camp_20261004_99999999", "session_id": "99999999"}, windows) is False +###################################################### +# 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_campaign_whose_id_does_not_encode_its_session_token_is_not_attributed(darkmoon): - """A substring-only match is no longer enough: the id must encode the same token.""" +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 - fake.campaigns["data"] = [{"id": "camp_20261004_deadbeef", "session_id": "5f3a9c1e", "date": "2026-10-04T10:15:30+00:00"}] - with pytest.raises(DarkmoonCorrelationError): - await tool.run_async(RUN_INPUT) - assert fake.count("vulnerabilities") == 0 + 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) -def test_subagent_campaign_is_never_attributed_helper(): - """A sub-agent campaign is excluded even when its verified token is a window of the run session. +##################################################### +# 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.""" - The server's own correlation (_finalize_stale_campaigns) skips `is_subagent` campaigns, so the - tool mirrors that: a sub-agent campaign merges into its parent and must never own a run's findings. - """ - windows = _session_windows("2a9e5f3a9c1eabcdef0123") - conformant = {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e"} - assert _owns_session(conformant, windows) is True - assert _owns_session(conformant | {"is_subagent": True}, windows) is False + 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_subagent_campaign_is_never_attributed(darkmoon): - fake, tool = darkmoon - fake.campaigns["data"] = [ - {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e", "is_subagent": True, "date": "2026-10-04T10:15:30+00:00"} - ] - with pytest.raises(DarkmoonCorrelationError, match="refusing to guess"): - await tool.run_async(RUN_INPUT) - assert fake.count("vulnerabilities") == 0 +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_concurrent_ambiguous_campaigns_refuse_rather_than_guess(darkmoon): - """Two concurrent runs whose non-subagent campaigns both encode a token present in this run's - session windows cannot be disambiguated from the contract (no run_id/owner on the campaign record). - The tool must fail safe and refuse, never return one campaign's findings as if it owned the run. +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 = [] - The run's parent session id is `ses_2a9e5f3a9c1eAbCdEf0123`; both `5f3a9c1e` and `2a9e5f3a` - are 8-char lowercase-hex windows of its normalised form, so both conformant campaigns are eligible. - """ - fake, tool = darkmoon - fake.campaigns["data"] = [ - {"id": "camp_20261004_5f3a9c1e", "session_id": "5f3a9c1e", "is_subagent": False, "date": "2026-10-04T10:15:30+00:00"}, - {"id": "camp_20261004_2a9e5f3a", "session_id": "2a9e5f3a", "is_subagent": False, "date": "2026-10-04T10:16:00+00:00"}, - ] - with pytest.raises(DarkmoonCorrelationError, match="2 campaigns match"): - await tool.run_async(RUN_INPUT) - assert fake.count("vulnerabilities") == 0 + 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 index fac71c10..c16936af 100644 --- a/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py +++ b/atomic-forge/tools/darkmoon_pentest/tool/darkmoon_pentest.py @@ -1,9 +1,8 @@ import asyncio import os -import re import time from concurrent.futures import ThreadPoolExecutor -from typing import Any, Dict, List, Literal, Optional, Set, Tuple +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple from urllib.parse import quote, urljoin, urlsplit import aiohttp @@ -21,25 +20,21 @@ MAX_REDIRECTS = 5 STATS_BUCKETS = ("by_severity", "by_category", "by_status") -# Campaign <-> run session mapping (see _campaign_session_token / _owns_session). +# Run <-> campaign attribution: NOT auto-attributed. # -# Darkmoon creates a campaign id as "camp__" where session_id -# is the dashboard MCP server's SESSION_ID = uuid4().hex[:8] (mcp/src/server.py -> -# api/live_push.py), so a well-formed campaign's session token is 8 lowercase hex -# chars AND the trailing segment of its id. We can verify that invariant offline. +# 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. # -# HONEST LIMITATION: that invariant is all the public contract gives us. A campaign -# record carries no run_id, no owner and no full session id (see api/live_push.py: -# the campaign dict has id/project_id/target_id/session_id/is_subagent/status/...), -# and POST /run/campaign returns only {run_id, pid, command} (api/routes_run.py), so -# there is NO unique run->campaign key to read. The only linkage the server itself -# uses is a substring/window match of the campaign's 8-char session token against the -# run's parent session id, restricted to non-subagent campaigns -# (_finalize_stale_campaigns in api/routes_run.py), which is a best-effort -# reconciliation sweep, not authoritative concurrent-run attribution. We therefore -# mirror that same rule and FAIL SAFE: zero or more than one eligible match raises -# rather than guessing. See the README "Campaign correlation contract" section. -CAMPAIGN_ID_RE = re.compile(r"^camp_\d{8}_([0-9a-f]{8})$") +# 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: @@ -62,10 +57,6 @@ class DarkmoonRunError(RuntimeError): """The Darkmoon run itself failed or was aborted. This is a failed assessment, not an empty result.""" -class DarkmoonCorrelationError(RuntimeError): - """A finished run could not be attributed to exactly one campaign, so no findings are returned for it.""" - - class DarkmoonRedirectError(RuntimeError): """The API tried to redirect a request to a different origin; it is refused so credentials never leave base_url.""" @@ -77,9 +68,12 @@ 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 findings), 'list_campaigns' to list existing campaigns, and 'get_findings' - to read the vulnerabilities and severity statistics of one campaign. Findings may contain false - positives and need human review. Requires a Darkmoon instance with the web dashboard (a Pro feature). + (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( @@ -95,7 +89,10 @@ class DarkmoonPentestToolInputSchema(BaseIOSchema): ) wait_for_completion: bool = Field( default=True, - description="For 'run_pentest': wait for the run to finish and return its findings. If false, return the run id only.", + 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." @@ -144,7 +141,11 @@ class DarkmoonPentestToolOutputSchema(BaseIOSchema): 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.") - campaign_id: Optional[str] = Field(None, description="Campaign the findings belong to.") + 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.") @@ -183,44 +184,6 @@ def _error_message(payload: Any, reason: Optional[str]) -> str: return detail if isinstance(detail, str) and detail else str(reason) -def _campaign_session_token(campaign: Dict[str, Any]) -> Optional[str]: - """The campaign's verified 8-hex session token, or None if the record is not contract-conformant. - - Darkmoon derives a campaign id as 'camp__' with - session_id = uuid4().hex[:8], so a well-formed record's session_id is exactly 8 - lowercase hex characters AND is the trailing segment of its id. A record that - satisfies only one half (e.g. an arbitrary 8-char substring that is not encoded - in the id) is NOT eligible for correlation. - """ - token = campaign.get("session_id") - match = CAMPAIGN_ID_RE.fullmatch(str(campaign.get("id", ""))) - if not (isinstance(token, str) and match and match.group(1) == token): - return None - return token - - -def _session_windows(session_id: str) -> Set[str]: - """Every 8-char window of the run's normalised parent session id.""" - return {session_id[i : i + 8] for i in range(len(session_id) - 7)} - - -def _owns_session(campaign: Dict[str, Any], windows: Set[str]) -> bool: - """True when a non-subagent campaign's verified session token is one of the run session's windows. - - Mirrors the server's own correlation rule (_finalize_stale_campaigns in - api/routes_run.py: a campaign's 8-char session token is a substring of the run's - parent session id, and sub-agent campaigns are skipped), tightened here so the - token must also be the one encoded in the campaign id, never a bare substring - coincidence. This narrows false matches but does NOT by itself guarantee unique - concurrent-run attribution (the contract exposes no run->campaign key); the caller - fails safe when more than one campaign is eligible. - """ - if campaign.get("is_subagent"): - return False - token = _campaign_session_token(campaign) - return token is not None and token in windows - - def _clean_focus(focus: Optional[List[str]]) -> List[str]: return [f.strip() for f in focus or [] if f.strip()] @@ -269,42 +232,33 @@ def _require_count(value: Any, what: str) -> int: return value -def _require_stat_bucket(key: str, bucket: Any) -> None: +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(): - _require_count(count, f"stats.{key}.{name}") + # 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) -> Dict[str, Any]: +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: - bucket = stats.get(key) - if bucket is not None: - _require_stat_bucket(key, bucket) + 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) -> Dict[str, Any]: +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", {})) + stats = _require_stats(body.get("stats", {}), scrub) return {"total": total, "stats": stats, "findings": findings} -def _first_session_id(events: List[Any]) -> Optional[str]: - """The parent opencode session id: the first top-level sessionID in the run log (as the Dashboard API uses it).""" - for event in events: - session_id = event.get("sessionID") if isinstance(event, dict) else None - if isinstance(session_id, str) and session_id: - return session_id.replace("ses_", "").replace("-", "") - return None - - 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": @@ -324,25 +278,30 @@ def _major_of(version: Any) -> Optional[int]: return int(major) if major.isdigit() else None -def _check_api_version(info: Any) -> 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. + 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( - f"Unsupported or missing Darkmoon Dashboard API version {data.get('api_version')!r}; " - f"this tool supports major {SUPPORTED_API_MAJOR}." + 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( - f"Unsupported or missing Darkmoon Dashboard API contract_version {data.get('contract_version')!r}; " - f"this tool supports contract {SUPPORTED_CONTRACT_VERSION}." + scrub( + f"Unsupported or missing Darkmoon Dashboard API contract_version {data.get('contract_version')!r}; " + f"this tool supports contract {SUPPORTED_CONTRACT_VERSION}." + ) ) @@ -401,8 +360,9 @@ def _check_settings(self) -> None: def _auth_headers(self, token: Optional[str]) -> Dict[str, str]: return {"Authorization": f"Bearer {token}"} if token else {} - def _request_timeout(self, timeout: Optional[float]) -> aiohttp.ClientTimeout: - return aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) + 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, @@ -415,13 +375,24 @@ async def _request( ) -> 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.""" + 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) - limit = self._request_timeout(timeout) + 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}.") @@ -475,14 +446,14 @@ async def _verify_api_version(self, session: aiohttp.ClientSession) -> None: if exc.status != 404: raise return - _check_api_version(info) + _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)) + 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).""" @@ -530,34 +501,6 @@ def _raise_if_failed(self, terminal: Dict[str, Any], run_id: str) -> None: if reason: raise DarkmoonRunError(self._scrub(f"Darkmoon run {run_id} failed: {reason}.")) - async def _campaign_of_run( - self, session: aiohttp.ClientSession, token: str, run_id: str, events: List[Any] - ) -> Dict[str, Any]: - """The one campaign this run can be safely attributed to, matched through the run's opencode session id. - - A non-subagent campaign is eligible when its verified session token (8 lowercase hex chars that are also - the trailing segment of its 'camp__' id) is one of the 8-char windows of the run's parent - session id. This mirrors the server's own correlation rule (_finalize_stale_campaigns), tightened so the - token must be the one encoded in the campaign id rather than a bare substring. Newest-campaign guessing is - never used. - - The contract exposes no unique run->campaign key (no run_id/owner/full session id on the campaign record), - so this correlation cannot by itself guarantee attribution across truly concurrent runs. The function is - therefore conservative: zero OR more than one eligible campaign raises DarkmoonCorrelationError instead of - returning a possibly-wrong campaign's findings. - """ - session_id = _first_session_id(events) - if not session_id: - raise DarkmoonCorrelationError(f"Run {run_id} finished but its log has no session id to correlate a campaign.") - windows = _session_windows(session_id) - matches = [c for c in await self._list_campaigns(session, token) if _owns_session(c, windows)] - if len(matches) != 1: - raise DarkmoonCorrelationError( - f"Run {run_id} finished but {len(matches)} campaigns match its session; refusing to guess. " - "Use 'list_campaigns' and 'get_findings'." - ) - return matches[0] - @staticmethod def _run_body(params: DarkmoonPentestToolInputSchema) -> Dict[str, Any]: optional = { @@ -570,19 +513,22 @@ def _run_body(params: DarkmoonPentestToolInputSchema) -> Dict[str, Any]: 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) + result = DarkmoonPentestToolOutputSchema(action=params.action, run_id=run_id, run_status="started") if not params.wait_for_completion: return result - result.timed_out, events = await self._wait_for_run(session, token, run_id) - if result.timed_out: - return result - campaign = await self._campaign_of_run(session, token, run_id, events) - result.campaign_id = str(campaign["id"]) - findings = await self._get_findings(session, token, result.campaign_id) - result.total, result.stats, result.findings = findings["total"], findings["stats"], findings["findings"] + 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(