Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions atomic-forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions atomic-forge/tools/darkmoon_pentest/.coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[run]
source = tool
omit = */tests/*

[report]
exclude_lines =
if __name__ == "__main__":
show_missing = True
97 changes: 97 additions & 0 deletions atomic-forge/tools/darkmoon_pentest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Darkmoon Pentest Tool

## Overview
Runs autonomous penetration tests with [Darkmoon](https://github.com/ASCIT31/Dark-Moon) (GPL-3.0, an LLM orchestrates specialist agents and offensive tools and proves findings with real exploits) and reads campaigns and findings back, through the Dashboard API of a Darkmoon instance that you operate yourself. There is no public hosted endpoint.

The tool logs in with `POST /api/v1/auth/login`, then uses the returned token for `POST /api/v1/run/campaign`, `GET /api/v1/run/logs/{run_id}`, `GET /api/v1/campaigns` and `GET /api/v1/vulnerabilities`.

**Open source vs Pro:** the Darkmoon engine and CLI are open source. The web dashboard and its API, which this tool talks to, are part of Darkmoon Pro. Without a dashboard, use the open source CLI and read its JSON or SARIF output instead.

**Supported API version:** Darkmoon Pro Dashboard API major version `1` (OpenAPI app version `1.0.0`, reported as `api_version` by `GET /api/v1/system/info`, `contract_version` `"1"`), as shipped with Darkmoon Pro v1.4.x. The tool reads `/api/v1/system/info` first and fails closed on any incompatible or malformed contract: a present (`HTTP 200`) response must be a JSON object whose `api_version` major is `1` **and** whose `contract_version` is `"1"`. A garbage `api_version`, a missing field, a different major, or a `contract_version` other than `"1"` is rejected before authenticating. The "treat as unversioned and accept" fallback applies only when the route is **absent** (`HTTP 404`), for older instances that predate `/system/info`. The response shapes the tool depends on are pinned by the JSON contract fixtures in `tests/fixtures/` (see the README there for how they were derived and their limits).

Only run assessments against systems you own or are explicitly authorised to test. Findings can include false positives and must be reviewed by a human.

## Prerequisites and Dependencies
- Python 3.12 or later
- `atomic-agents`
- `pydantic`
- `aiohttp`
- A running Darkmoon instance with the dashboard enabled, and a dashboard user

## Installation
1. Use the Atomic Assembler CLI: run `atomic` and pick `darkmoon_pentest`.
2. Or copy the `tool/` folder directly into your project.

## Configuration
- `base_url` (str): Dashboard API base URL, e.g. `http://localhost:8000`. Falls back to `DARKMOON_BASE_URL`.
- `username` (str): dashboard username. Falls back to `DARKMOON_USERNAME`.
- `password` (str): dashboard password. Falls back to `DARKMOON_PASSWORD`.
- `timeout` (float): HTTP timeout per request in seconds (default 60).
- `run_timeout` (float): maximum seconds to wait for a pentest run to finish (default 1800).
- `poll_interval` (float): seconds between run status checks (default 5).

## Input & Output Structure

### Input Schema
- `action` (str): `run_pentest`, `list_campaigns` or `get_findings`.
- `target` (str): host, URL or scope to assess. Required for `run_pentest`.
- `campaign_id` (str): campaign to read. Required for `get_findings`.
- `wait_for_completion` (bool): for `run_pentest`, wait for the run to finish and report its status (not its findings; default `True`).
- `program` (str), `focus` (list[str]), `min_severity` (`info`, `low`, `medium`, `high`, `critical`): optional run settings.

### Output Schema
- `action`, `run_id`, `run_status` (`started`, `completed` or `timed_out`), `campaign_id` (set only by `get_findings`), `timed_out`
- `campaigns`: list of `DarkmoonCampaign` (`id`, `date`)
- `total`, `stats`: finding count and the severity statistics reported by the dashboard
- `findings`: list of `DarkmoonFinding` (`title`, `severity`, `cvss_score`, `category`, `status`, `endpoint`, `cve`, `description`, `remediation`)

#### Why findings are not auto-attributed to a run

`run_pentest` returns the run's **status**, not its findings. To read findings, call `list_campaigns` and then `get_findings` with the campaign id you choose.

This is deliberate. The public contract exposes **no verifiable run→campaign key**. The campaign record (built in `mcp/api/live_push.py`, the `campaign = {...}` dict) carries only `id / project_id / target_id / session_id / is_subagent / date / status / stats / ...` — no `run_id`, no owner, and no full session id. Its `session_id` is the dashboard MCP server's independent `SESSION_ID = uuid4().hex[:8]` (`mcp/src/server.py`), and the Pro run-start response carries no campaign id. The only linkage the server itself performs is a best-effort reconciliation sweep that matches a campaign's 8-char `session_id` as a substring window of a run's parent session id — a heuristic, not an authoritative ownership proof.

Matching on a session-id suffix/window therefore cannot establish ownership: a run whose session merely *contains* an 8-char hex sequence that happens to equal some old campaign's `session_id` would be paired with that unrelated campaign and return the wrong findings. Rather than claim a correlation the contract cannot back, the tool does not attribute findings to a run at all; it reports whether the run `started`, `completed`, or `timed_out`, and leaves campaign selection explicit. A failed run is still surfaced as a failure (see below), never as an empty result.

Failure and status handling, all raised as exceptions rather than returned as an empty result:
- `DarkmoonRunError`: the run ended with `run_error`, was aborted, or opencode exited non-zero. The message includes the run id.
- `DarkmoonAPIError`: an HTTP error status from the API (`.status` holds the code). Only an HTTP 404 on the run log is treated as "the run has not written its log yet".
- `DarkmoonSchemaError`: a response did not have the expected shape (missing `data` list, missing `run_id` or `token`, a finding without a title, `cvss_score` outside 0 to 10, a negative or non-integer `total`, a `stats` bucket that is not an object — including an explicit `null` — or a bucket count that is negative or non-integer, or a present-but-incompatible/malformed version contract). Version values and stats bucket names are scrubbed of secrets before they appear in the message.
- `DarkmoonRedirectError`: the API tried to redirect a request to a different origin, or returned an unusable/looping redirect. The tool refuses it so credentials are never replayed off `base_url`.

`run_timeout` is a hard deadline: both the sleeps between polls and each poll request are capped by the time that remains, so the call returns within about `run_timeout` seconds. A request's whole budget (not a fresh budget per redirect hop) is shared across every hop and rechecked before a terminal result is accepted, so a chain of redirects cannot overrun the deadline. If the wait times out, `run_status` is `timed_out`, `timed_out` is `True`, and the run keeps going on the Darkmoon side (read it later with `list_campaigns` and `get_findings`).

**Credential and secret safety.** TLS certificates are always verified. Redirects are resolved by hand (the HTTP client's automatic redirect following is disabled): a `3xx` whose `Location` points to a different origin is refused rather than followed, so a hostile or misconfigured `307` can never replay the login POST body or the bearer token to another host; same-origin redirects (for example a trailing-slash `307`) are followed, bounded to a few hops. The dashboard password is excluded from the config and tool `repr`, and both the password and the bearer token are scrubbed from every raised exception message, so a verbose or hostile API `detail` or run-error text cannot leak them.

## Tests
`uv run pytest atomic-forge/tools/darkmoon_pentest` runs the suite against an in-process imitation of the Dashboard API whose response bodies are the contract fixtures. The imitation server and the fixtures were written from the Dashboard API source and the published `@darkmoon_ai/client` 0.2.0, so they pin the shapes this tool expects but do not replace a run against a real Pro instance.

## Usage

```python
from tool.darkmoon_pentest import (
DarkmoonPentestTool,
DarkmoonPentestToolConfig,
DarkmoonPentestToolInputSchema,
)

tool = DarkmoonPentestTool(
config=DarkmoonPentestToolConfig(
base_url="http://localhost:8000",
username="analyst",
password="your-dashboard-password",
)
)

campaigns = tool.run(DarkmoonPentestToolInputSchema(action="list_campaigns"))
report = tool.run(DarkmoonPentestToolInputSchema(action="get_findings", campaign_id=campaigns.campaigns[0].id))

for finding in report.findings:
print(finding.severity, finding.title, finding.endpoint)
```

## Contributing
PRs welcome. See the main repo `CONTRIBUTING.md`.

## License
Same as the main Atomic Agents project.
34 changes: 34 additions & 0 deletions atomic-forge/tools/darkmoon_pentest/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
3 changes: 3 additions & 0 deletions atomic-forge/tools/darkmoon_pentest/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions atomic-forge/tools/darkmoon_pentest/tests/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -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=...` |
Original file line number Diff line number Diff line change
@@ -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"}
]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"token": "eyJhbGciOiJIUzI1NiJ9.fixture.signature", "must_change_password": false, "user": {"username": "analyst", "role": "admin"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"run_id": "run_20261004_101500_a1b2c3d4", "pid": 4242, "command": "Run a pentest on target.example"}
Original file line number Diff line number Diff line change
@@ -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"}
]}
Original file line number Diff line number Diff line change
@@ -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"}
]}
Original file line number Diff line number Diff line change
@@ -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"}
]}
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
@@ -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 ..."]}}
]}
Loading
Loading