lfx-darkmoon 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lfx_darkmoon-0.1.0/.gitignore +12 -0
- lfx_darkmoon-0.1.0/LICENSE +21 -0
- lfx_darkmoon-0.1.0/PKG-INFO +111 -0
- lfx_darkmoon-0.1.0/README.md +87 -0
- lfx_darkmoon-0.1.0/pyproject.toml +65 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/__init__.py +25 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/__init__.py +23 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/_base.py +52 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/darkmoon.svg +21 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/darkmoon_client.py +348 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/get_findings.py +45 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/list_campaigns.py +27 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/list_pull_requests.py +52 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/run_pentest.py +127 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/components/darkmoon/transport.py +46 -0
- lfx_darkmoon-0.1.0/src/lfx_darkmoon/extension.json +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ASC-IT (Darkmoon)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lfx-darkmoon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Darkmoon components as a standalone Langflow Extension Bundle: run an authorised AI pentest, pull findings, and review the fix pull requests Darkmoon prepares.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ASCIT31/Dark-Moon
|
|
6
|
+
Project-URL: Documentation, https://docs.langflow.org/extensions
|
|
7
|
+
Project-URL: Source, https://github.com/ASCIT31/langflow-darkmoon
|
|
8
|
+
Project-URL: Issues, https://github.com/ASCIT31/langflow-darkmoon/issues
|
|
9
|
+
Author: ASC-IT (Darkmoon)
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-security,appsec,bundle,darkmoon,dast,extension,langflow,lfx,pentest,security
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: <3.15,>=3.10
|
|
19
|
+
Requires-Dist: lfx<2.0.0,>=1.12.0
|
|
20
|
+
Requires-Dist: requests>=2.28
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# langflow-darkmoon
|
|
26
|
+
|
|
27
|
+
[](https://github.com/ASCIT31/Dark-Moon)
|
|
28
|
+
[](https://github.com/ASCIT31/Dark-Moon)
|
|
29
|
+
|
|
30
|
+
A [Langflow](https://github.com/langflow-ai/langflow) **Extension Bundle** (distribution `lfx-darkmoon`) for **[Darkmoon](https://github.com/ASCIT31/Dark-Moon)**, the local, privacy first autonomous AI penetration testing engine (GPLv3). Drop these components into a Langflow flow to **trigger a Darkmoon pentest against a target you are authorised to assess, pull back the findings, and review the fix pull requests Darkmoon prepares**, over your own self-hosted Darkmoon Dashboard API.
|
|
31
|
+
|
|
32
|
+
> Darkmoon runs and validates security tests. It does not, and these components do not, guarantee that a system is secure. Findings can include false positives and must be reviewed by a qualified human. Only run assessments against systems you own or have explicit written authorisation to test. These components never merge a pull request; every fix is left for a person to review and merge.
|
|
33
|
+
|
|
34
|
+
## Components
|
|
35
|
+
|
|
36
|
+
| Component | Purpose |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| **Darkmoon Run Pentest** | Start a campaign against an authorised target; optionally wait and return findings and severity stats. |
|
|
39
|
+
| **Darkmoon Get Findings** | Return the vulnerabilities and aggregated stats for a campaign id. |
|
|
40
|
+
| **Darkmoon List Campaigns** | List the campaigns visible to the authenticated dashboard user. |
|
|
41
|
+
| **Darkmoon List Pull Requests** | List the fix pull requests Darkmoon prepared (read only), optionally scoped and filtered by state. |
|
|
42
|
+
|
|
43
|
+
The bundle registers under the `darkmoon` group. Each component is addressable by the canonical namespaced id `ext:darkmoon:<Class>@official`, for example `ext:darkmoon:DarkmoonRunPentest@official`.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Darkmoon is self hosted, so you point the components at your own **Darkmoon Dashboard API** (the FastAPI service shipped with Darkmoon, typically port `8000`). There is no public endpoint. Each component logs in with `POST /api/v1/auth/login` to obtain a short lived JWT.
|
|
48
|
+
|
|
49
|
+
This is a standalone [Langflow Extension Bundle](https://docs.langflow.org/extensions): install it alongside Langflow and it is discovered automatically through the `langflow.extensions` entry point. No fork of Langflow and no `LANGFLOW_COMPONENTS_PATH` are required.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install langflow lfx-darkmoon
|
|
53
|
+
langflow run
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Restart the Langflow server; the four Darkmoon components appear in the visual editor under the **Darkmoon** bundle group. Set the base URL, username and password on a component, and a target on **Darkmoon Run Pentest**.
|
|
57
|
+
|
|
58
|
+
## Remediation and secrets (Pro)
|
|
59
|
+
|
|
60
|
+
The Darkmoon dashboard and the remediation feature that prepares fix pull requests are paid **Pro** capabilities. The open source Darkmoon (the CLI engine) focuses on finding, proving and reporting findings locally. When remediation is enabled it needs a **credential reference**, an opaque id of a credential stored in Darkmoon's encrypted vault (created in the dashboard), not a raw token. Raw source control secrets never travel through these components, and the remediation agent only ever prepares a pull request for human review; it never merges. **Darkmoon List Pull Requests** can read those pull requests through the API.
|
|
61
|
+
|
|
62
|
+
## Privacy
|
|
63
|
+
|
|
64
|
+
Darkmoon keeps assessment work on your own infrastructure and applies a privacy gateway so the language model works over placeholders in place of your real hosts, IPs and credentials. These components send data only to the base URL you configure.
|
|
65
|
+
|
|
66
|
+
## Darkmoon at a glance
|
|
67
|
+
|
|
68
|
+
Darkmoon is a GPLv3, self hosted autonomous AI pentester: an LLM orchestrates specialist agents and offensive tools over MCP, runs on a local model, and proves each finding with a real exploit. See [ASCIT31/Dark-Moon](https://github.com/ASCIT31/Dark-Moon).
|
|
69
|
+
|
|
70
|
+
## Layout
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
langflow-darkmoon/
|
|
74
|
+
├── pyproject.toml # distribution: lfx-darkmoon
|
|
75
|
+
├── src/
|
|
76
|
+
│ └── lfx_darkmoon/
|
|
77
|
+
│ ├── __init__.py # re-exports the four components
|
|
78
|
+
│ ├── extension.json # Extension Bundle manifest (v1)
|
|
79
|
+
│ └── components/
|
|
80
|
+
│ └── darkmoon/ # the bundle (path in extension.json)
|
|
81
|
+
│ ├── run_pentest.py
|
|
82
|
+
│ ├── get_findings.py
|
|
83
|
+
│ ├── list_campaigns.py
|
|
84
|
+
│ ├── list_pull_requests.py
|
|
85
|
+
│ ├── _base.py # shared inputs + client mixin (not a Component)
|
|
86
|
+
│ ├── darkmoon_client.py# dependency free, transport injected API client
|
|
87
|
+
│ └── transport.py # requests transport + client factory
|
|
88
|
+
└── tests/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install langflow # brings lfx (the Bundle API surface)
|
|
95
|
+
pip install -e ".[test]" # install this bundle editable
|
|
96
|
+
lfx extension validate src/lfx_darkmoon # static manifest + bundle check
|
|
97
|
+
lfx extension list # confirm it is discovered at @official
|
|
98
|
+
pytest -q
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The API client (`darkmoon_client.py`) is dependency free and transport injected, so its logic is unit tested without a network (mock transport). The component tests instantiate the real Langflow (`lfx`) components, inject a mock transport, run each output, and assert each builds a valid Langflow frontend node. A full end to end run additionally requires a running Darkmoon instance pointed at an authorised target.
|
|
102
|
+
|
|
103
|
+
Intra bundle imports are absolute (`from lfx_darkmoon.components.darkmoon...`), as required by the Langflow bundle loader, which does not support relative imports between bundle modules.
|
|
104
|
+
|
|
105
|
+
## Related integrations
|
|
106
|
+
|
|
107
|
+
Darkmoon also ships integrations for [n8n](https://github.com/ASCIT31/n8n-nodes-darkmoon), [Activepieces](https://github.com/ASCIT31/activepieces-piece-darkmoon), [Dify](https://github.com/ASCIT31/dify-plugin-darkmoon), [LangChain](https://github.com/ASCIT31/langchain-darkmoon) and [GitHub Actions](https://github.com/ASCIT31/darkmoon-scan-action).
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT, see [LICENSE](./LICENSE). Not affiliated with Langflow; "Langflow" is a trademark of its respective owner.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# langflow-darkmoon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ASCIT31/Dark-Moon)
|
|
4
|
+
[](https://github.com/ASCIT31/Dark-Moon)
|
|
5
|
+
|
|
6
|
+
A [Langflow](https://github.com/langflow-ai/langflow) **Extension Bundle** (distribution `lfx-darkmoon`) for **[Darkmoon](https://github.com/ASCIT31/Dark-Moon)**, the local, privacy first autonomous AI penetration testing engine (GPLv3). Drop these components into a Langflow flow to **trigger a Darkmoon pentest against a target you are authorised to assess, pull back the findings, and review the fix pull requests Darkmoon prepares**, over your own self-hosted Darkmoon Dashboard API.
|
|
7
|
+
|
|
8
|
+
> Darkmoon runs and validates security tests. It does not, and these components do not, guarantee that a system is secure. Findings can include false positives and must be reviewed by a qualified human. Only run assessments against systems you own or have explicit written authorisation to test. These components never merge a pull request; every fix is left for a person to review and merge.
|
|
9
|
+
|
|
10
|
+
## Components
|
|
11
|
+
|
|
12
|
+
| Component | Purpose |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| **Darkmoon Run Pentest** | Start a campaign against an authorised target; optionally wait and return findings and severity stats. |
|
|
15
|
+
| **Darkmoon Get Findings** | Return the vulnerabilities and aggregated stats for a campaign id. |
|
|
16
|
+
| **Darkmoon List Campaigns** | List the campaigns visible to the authenticated dashboard user. |
|
|
17
|
+
| **Darkmoon List Pull Requests** | List the fix pull requests Darkmoon prepared (read only), optionally scoped and filtered by state. |
|
|
18
|
+
|
|
19
|
+
The bundle registers under the `darkmoon` group. Each component is addressable by the canonical namespaced id `ext:darkmoon:<Class>@official`, for example `ext:darkmoon:DarkmoonRunPentest@official`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Darkmoon is self hosted, so you point the components at your own **Darkmoon Dashboard API** (the FastAPI service shipped with Darkmoon, typically port `8000`). There is no public endpoint. Each component logs in with `POST /api/v1/auth/login` to obtain a short lived JWT.
|
|
24
|
+
|
|
25
|
+
This is a standalone [Langflow Extension Bundle](https://docs.langflow.org/extensions): install it alongside Langflow and it is discovered automatically through the `langflow.extensions` entry point. No fork of Langflow and no `LANGFLOW_COMPONENTS_PATH` are required.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install langflow lfx-darkmoon
|
|
29
|
+
langflow run
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Restart the Langflow server; the four Darkmoon components appear in the visual editor under the **Darkmoon** bundle group. Set the base URL, username and password on a component, and a target on **Darkmoon Run Pentest**.
|
|
33
|
+
|
|
34
|
+
## Remediation and secrets (Pro)
|
|
35
|
+
|
|
36
|
+
The Darkmoon dashboard and the remediation feature that prepares fix pull requests are paid **Pro** capabilities. The open source Darkmoon (the CLI engine) focuses on finding, proving and reporting findings locally. When remediation is enabled it needs a **credential reference**, an opaque id of a credential stored in Darkmoon's encrypted vault (created in the dashboard), not a raw token. Raw source control secrets never travel through these components, and the remediation agent only ever prepares a pull request for human review; it never merges. **Darkmoon List Pull Requests** can read those pull requests through the API.
|
|
37
|
+
|
|
38
|
+
## Privacy
|
|
39
|
+
|
|
40
|
+
Darkmoon keeps assessment work on your own infrastructure and applies a privacy gateway so the language model works over placeholders in place of your real hosts, IPs and credentials. These components send data only to the base URL you configure.
|
|
41
|
+
|
|
42
|
+
## Darkmoon at a glance
|
|
43
|
+
|
|
44
|
+
Darkmoon is a GPLv3, self hosted autonomous AI pentester: an LLM orchestrates specialist agents and offensive tools over MCP, runs on a local model, and proves each finding with a real exploit. See [ASCIT31/Dark-Moon](https://github.com/ASCIT31/Dark-Moon).
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
langflow-darkmoon/
|
|
50
|
+
├── pyproject.toml # distribution: lfx-darkmoon
|
|
51
|
+
├── src/
|
|
52
|
+
│ └── lfx_darkmoon/
|
|
53
|
+
│ ├── __init__.py # re-exports the four components
|
|
54
|
+
│ ├── extension.json # Extension Bundle manifest (v1)
|
|
55
|
+
│ └── components/
|
|
56
|
+
│ └── darkmoon/ # the bundle (path in extension.json)
|
|
57
|
+
│ ├── run_pentest.py
|
|
58
|
+
│ ├── get_findings.py
|
|
59
|
+
│ ├── list_campaigns.py
|
|
60
|
+
│ ├── list_pull_requests.py
|
|
61
|
+
│ ├── _base.py # shared inputs + client mixin (not a Component)
|
|
62
|
+
│ ├── darkmoon_client.py# dependency free, transport injected API client
|
|
63
|
+
│ └── transport.py # requests transport + client factory
|
|
64
|
+
└── tests/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install langflow # brings lfx (the Bundle API surface)
|
|
71
|
+
pip install -e ".[test]" # install this bundle editable
|
|
72
|
+
lfx extension validate src/lfx_darkmoon # static manifest + bundle check
|
|
73
|
+
lfx extension list # confirm it is discovered at @official
|
|
74
|
+
pytest -q
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The API client (`darkmoon_client.py`) is dependency free and transport injected, so its logic is unit tested without a network (mock transport). The component tests instantiate the real Langflow (`lfx`) components, inject a mock transport, run each output, and assert each builds a valid Langflow frontend node. A full end to end run additionally requires a running Darkmoon instance pointed at an authorised target.
|
|
78
|
+
|
|
79
|
+
Intra bundle imports are absolute (`from lfx_darkmoon.components.darkmoon...`), as required by the Langflow bundle loader, which does not support relative imports between bundle modules.
|
|
80
|
+
|
|
81
|
+
## Related integrations
|
|
82
|
+
|
|
83
|
+
Darkmoon also ships integrations for [n8n](https://github.com/ASCIT31/n8n-nodes-darkmoon), [Activepieces](https://github.com/ASCIT31/activepieces-piece-darkmoon), [Dify](https://github.com/ASCIT31/dify-plugin-darkmoon), [LangChain](https://github.com/ASCIT31/langchain-darkmoon) and [GitHub Actions](https://github.com/ASCIT31/darkmoon-scan-action).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT, see [LICENSE](./LICENSE). Not affiliated with Langflow; "Langflow" is a trademark of its respective owner.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lfx-darkmoon"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Darkmoon components as a standalone Langflow Extension Bundle: run an authorised AI pentest, pull findings, and review the fix pull requests Darkmoon prepares."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10,<3.15"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "ASC-IT (Darkmoon)" }]
|
|
9
|
+
keywords = ["langflow", "lfx", "extension", "bundle", "darkmoon", "pentest", "security", "dast", "appsec", "ai-security"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Security",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Runtime deps: lfx (the public BUNDLE_API surface these components are built
|
|
19
|
+
# against) and requests (the HTTP transport). lfx is floored at the current
|
|
20
|
+
# major.minor line and capped below the next lfx major; fine-grained BUNDLE_API
|
|
21
|
+
# compatibility is enforced separately via extension.json's
|
|
22
|
+
# "lfx": {"compat": ["1"]} contract against the running lfx's BUNDLE_API_VERSION.
|
|
23
|
+
dependencies = [
|
|
24
|
+
"lfx>=1.12.0,<2.0.0",
|
|
25
|
+
"requests>=2.28",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest>=8"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/ASCIT31/Dark-Moon"
|
|
33
|
+
Documentation = "https://docs.langflow.org/extensions"
|
|
34
|
+
Source = "https://github.com/ASCIT31/langflow-darkmoon"
|
|
35
|
+
Issues = "https://github.com/ASCIT31/langflow-darkmoon/issues"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling>=1.31.0"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
# Manifest-shipping distributions are discovered via the ``langflow.extensions``
|
|
42
|
+
# entry-point. The value is the dotted path to the package containing
|
|
43
|
+
# ``extension.json``; the loader walks ``importlib.metadata.files()`` from
|
|
44
|
+
# there to find the manifest, and falls back to this entry-point for editable
|
|
45
|
+
# installs whose dist.files only surfaces dist-info entries.
|
|
46
|
+
[project.entry-points."langflow.extensions"]
|
|
47
|
+
lfx-darkmoon = "lfx_darkmoon"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
# extension.json + components live inside the lfx_darkmoon package so
|
|
51
|
+
# ``importlib.metadata.files(dist)`` finds them and the loader resolves
|
|
52
|
+
# bundles[].path relative to the manifest's directory.
|
|
53
|
+
packages = ["src/lfx_darkmoon"]
|
|
54
|
+
include = [
|
|
55
|
+
"src/lfx_darkmoon/extension.json",
|
|
56
|
+
"src/lfx_darkmoon/components/**/*.py",
|
|
57
|
+
"src/lfx_darkmoon/components/**/*.svg",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.sdist]
|
|
61
|
+
include = [
|
|
62
|
+
"src/lfx_darkmoon",
|
|
63
|
+
"README.md",
|
|
64
|
+
"pyproject.toml",
|
|
65
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""lfx-darkmoon: Darkmoon components as a standalone Langflow Extension Bundle.
|
|
2
|
+
|
|
3
|
+
This package is the distribution unit ``lfx-darkmoon``. At runtime Langflow's
|
|
4
|
+
loader discovers the ``extension.json`` shipped alongside this ``__init__.py``
|
|
5
|
+
and registers the four Darkmoon components under namespaced IDs such as
|
|
6
|
+
``ext:darkmoon:DarkmoonRunPentest@official``.
|
|
7
|
+
|
|
8
|
+
Darkmoon is the local, privacy-first autonomous AI penetration testing engine
|
|
9
|
+
(GPLv3): https://github.com/ASCIT31/Dark-Moon. These components talk to a
|
|
10
|
+
self-hosted Darkmoon Dashboard API; there is no public hosted endpoint.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from lfx_darkmoon.components.darkmoon import (
|
|
14
|
+
DarkmoonGetFindingsComponent,
|
|
15
|
+
DarkmoonListCampaignsComponent,
|
|
16
|
+
DarkmoonListPullRequestsComponent,
|
|
17
|
+
DarkmoonRunPentestComponent,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DarkmoonRunPentestComponent",
|
|
22
|
+
"DarkmoonGetFindingsComponent",
|
|
23
|
+
"DarkmoonListCampaignsComponent",
|
|
24
|
+
"DarkmoonListPullRequestsComponent",
|
|
25
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Darkmoon bundle for Langflow.
|
|
2
|
+
|
|
3
|
+
The ``darkmoon`` bundle of the ``lfx-darkmoon`` Extension Bundle. It ships four
|
|
4
|
+
components: Run Pentest, Get Findings, List Campaigns and List Pull Requests,
|
|
5
|
+
over a self-hosted Darkmoon Dashboard API. Langflow discovers them via the
|
|
6
|
+
manifest at ``lfx_darkmoon/extension.json``; each registers under the namespaced
|
|
7
|
+
id ``ext:darkmoon:<Class>@official``. See https://github.com/ASCIT31/Dark-Moon.
|
|
8
|
+
"""
|
|
9
|
+
from lfx_darkmoon.components.darkmoon.darkmoon_client import DarkmoonClient, DarkmoonError, PR_STATES
|
|
10
|
+
from lfx_darkmoon.components.darkmoon.get_findings import DarkmoonGetFindingsComponent
|
|
11
|
+
from lfx_darkmoon.components.darkmoon.list_campaigns import DarkmoonListCampaignsComponent
|
|
12
|
+
from lfx_darkmoon.components.darkmoon.list_pull_requests import DarkmoonListPullRequestsComponent
|
|
13
|
+
from lfx_darkmoon.components.darkmoon.run_pentest import DarkmoonRunPentestComponent
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DarkmoonClient",
|
|
17
|
+
"DarkmoonError",
|
|
18
|
+
"PR_STATES",
|
|
19
|
+
"DarkmoonRunPentestComponent",
|
|
20
|
+
"DarkmoonGetFindingsComponent",
|
|
21
|
+
"DarkmoonListCampaignsComponent",
|
|
22
|
+
"DarkmoonListPullRequestsComponent",
|
|
23
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared connection inputs and client helper for the Darkmoon Langflow components."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from lfx.io import SecretStrInput, StrInput
|
|
5
|
+
|
|
6
|
+
from lfx_darkmoon.components.darkmoon.darkmoon_client import DarkmoonClient
|
|
7
|
+
from lfx_darkmoon.components.darkmoon.transport import logged_in_client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def connection_inputs():
|
|
11
|
+
"""Fresh connection input instances (base URL / username / password).
|
|
12
|
+
|
|
13
|
+
Darkmoon is self-hosted, so the base URL is always supplied by the operator;
|
|
14
|
+
there is no public hosted endpoint.
|
|
15
|
+
"""
|
|
16
|
+
return [
|
|
17
|
+
StrInput(
|
|
18
|
+
name="base_url",
|
|
19
|
+
display_name="Darkmoon base URL",
|
|
20
|
+
info="Base URL of your self-hosted Darkmoon Dashboard API (FastAPI, typically port 8000).",
|
|
21
|
+
required=True,
|
|
22
|
+
),
|
|
23
|
+
StrInput(
|
|
24
|
+
name="username",
|
|
25
|
+
display_name="Dashboard username",
|
|
26
|
+
info="A Darkmoon dashboard user.",
|
|
27
|
+
required=True,
|
|
28
|
+
),
|
|
29
|
+
SecretStrInput(
|
|
30
|
+
name="password",
|
|
31
|
+
display_name="Dashboard password",
|
|
32
|
+
info="The dashboard user's password. Used only to obtain a short-lived JWT.",
|
|
33
|
+
required=True,
|
|
34
|
+
),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DarkmoonClientMixin:
|
|
39
|
+
"""Mixin that builds an authenticated Darkmoon client per run.
|
|
40
|
+
|
|
41
|
+
This is deliberately NOT a ``Component`` subclass: each concrete component
|
|
42
|
+
inherits ``(DarkmoonClientMixin, Component)`` so the extension validator
|
|
43
|
+
sees exactly the four real palette components and no abstract base without a
|
|
44
|
+
``build()``/output method.
|
|
45
|
+
|
|
46
|
+
Tests monkeypatch :func:`logged_in_client` (a module global in this file) to
|
|
47
|
+
inject a mock-transport client, so component logic is exercised without a
|
|
48
|
+
network.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def _client(self) -> DarkmoonClient:
|
|
52
|
+
return logged_in_client(self.base_url, self.username, self.password)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Darkmoon">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="dm" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#312e81"/>
|
|
5
|
+
<stop offset="0.55" stop-color="#4f46e5"/>
|
|
6
|
+
<stop offset="1" stop-color="#eff6ff"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect width="64" height="64" rx="12" fill="#0b1020"/>
|
|
10
|
+
<g fill="url(#dm)">
|
|
11
|
+
<rect x="12" y="12" width="10" height="10" rx="2"/>
|
|
12
|
+
<rect x="26" y="12" width="10" height="10" rx="2"/>
|
|
13
|
+
<rect x="12" y="26" width="10" height="10" rx="2"/>
|
|
14
|
+
<rect x="40" y="26" width="10" height="10" rx="2"/>
|
|
15
|
+
<rect x="26" y="40" width="10" height="10" rx="2"/>
|
|
16
|
+
<rect x="40" y="40" width="10" height="10" rx="2"/>
|
|
17
|
+
<rect x="12" y="40" width="10" height="10" rx="2"/>
|
|
18
|
+
</g>
|
|
19
|
+
<circle cx="45" cy="19" r="7" fill="#eff6ff"/>
|
|
20
|
+
<circle cx="48.5" cy="17" r="6" fill="#0b1020"/>
|
|
21
|
+
</svg>
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DarkmoonClient - dependency-free client for the Darkmoon Dashboard API.
|
|
3
|
+
|
|
4
|
+
This module deliberately has NO Dify or third-party imports. It receives an
|
|
5
|
+
injected HTTP function so it can run both inside a Dify tool (wrapping
|
|
6
|
+
``requests``) and in a plain test harness (wrapping a mock). This keeps the
|
|
7
|
+
component logic free of heavy runtime dependencies and lets the integration be
|
|
8
|
+
unit-tested end to end without a live Darkmoon instance.
|
|
9
|
+
|
|
10
|
+
The Darkmoon Dashboard API is a versioned FastAPI service (self-hosted). The
|
|
11
|
+
endpoints used here mirror the ones the maintained n8n and Activepieces
|
|
12
|
+
Darkmoon integrations use, read from Dark-Moon-Front-API (branch dev):
|
|
13
|
+
|
|
14
|
+
POST /api/v1/auth/login -> { token, ... }
|
|
15
|
+
POST /api/v1/run/campaign -> { run_id, pid, command }
|
|
16
|
+
GET /api/v1/run/logs/{run_id} -> { data: events[], total }
|
|
17
|
+
DELETE /api/v1/run/{run_id}/stop -> { message, run_id }
|
|
18
|
+
GET /api/v1/campaigns -> { data: campaigns[], total }
|
|
19
|
+
GET /api/v1/vulnerabilities?campaign_id -> { data, total, stats }
|
|
20
|
+
GET /api/v1/campaigns/{id}/report -> { content, format }
|
|
21
|
+
GET /api/v1/pull-requests?campaign_id -> { data: PullRequest[], total }
|
|
22
|
+
|
|
23
|
+
There is no public hosted endpoint; every deployment is self-hosted, so the
|
|
24
|
+
base URL is always supplied by the operator.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
31
|
+
from urllib.parse import quote
|
|
32
|
+
|
|
33
|
+
# The exact PR states the store recognises (pr_store.PR_STATES).
|
|
34
|
+
PR_STATES = ("proposed", "draft", "open", "merged", "closed", "error")
|
|
35
|
+
|
|
36
|
+
# Run-log event types that mark a run as finished.
|
|
37
|
+
_TERMINAL_TYPES = frozenset({"run_completed", "run_error"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class HttpResponse:
|
|
42
|
+
"""Raw HTTP result the injected transport must return."""
|
|
43
|
+
|
|
44
|
+
status_code: int
|
|
45
|
+
body: Any
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Injected transport. Must return the raw status and parsed JSON body, and must
|
|
49
|
+
# NEVER raise on a non-2xx status - the client inspects status_code itself so
|
|
50
|
+
# error messages carry the API's own ``detail`` (and never a local secret).
|
|
51
|
+
HttpFn = Callable[[Dict[str, Any]], HttpResponse]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DarkmoonError(Exception):
|
|
55
|
+
"""Error carrying the API's own detail plus the HTTP status code."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.status_code = status_code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _detail(res: HttpResponse, fallback: str) -> str:
|
|
63
|
+
body = res.body if res else None
|
|
64
|
+
if isinstance(body, dict):
|
|
65
|
+
d = body.get("detail", body.get("message"))
|
|
66
|
+
if isinstance(d, str) and d:
|
|
67
|
+
return d
|
|
68
|
+
return fallback
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _data_list(res: HttpResponse) -> List[Any]:
|
|
72
|
+
body = res.body
|
|
73
|
+
if isinstance(body, dict) and isinstance(body.get("data"), list):
|
|
74
|
+
return body["data"]
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DarkmoonClient:
|
|
79
|
+
"""A thin, transport-injected client over the Darkmoon Dashboard API."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, base_url: str, http: HttpFn) -> None:
|
|
82
|
+
# Strip trailing slashes so path joins stay clean.
|
|
83
|
+
self.base_url = base_url.rstrip("/")
|
|
84
|
+
self._http = http
|
|
85
|
+
self._token: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
def _url(self, path: str) -> str:
|
|
88
|
+
return f"{self.base_url}{path}"
|
|
89
|
+
|
|
90
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
91
|
+
h: Dict[str, str] = {"Content-Type": "application/json"}
|
|
92
|
+
if self._token:
|
|
93
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
94
|
+
return h
|
|
95
|
+
|
|
96
|
+
# -- auth ---------------------------------------------------------------
|
|
97
|
+
def login(self, username: str, password: str) -> str:
|
|
98
|
+
"""Authenticate and cache the JWT for subsequent calls."""
|
|
99
|
+
res = self._http(
|
|
100
|
+
{
|
|
101
|
+
"method": "POST",
|
|
102
|
+
"url": self._url("/api/v1/auth/login"),
|
|
103
|
+
"headers": {"Content-Type": "application/json"},
|
|
104
|
+
"body": {"username": username, "password": password},
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
token = res.body.get("token") if isinstance(res.body, dict) else None
|
|
108
|
+
if res.status_code != 200 or not token:
|
|
109
|
+
raise DarkmoonError(_detail(res, "Darkmoon login failed"), res.status_code)
|
|
110
|
+
self._token = token
|
|
111
|
+
return token
|
|
112
|
+
|
|
113
|
+
# -- campaigns ----------------------------------------------------------
|
|
114
|
+
def list_campaigns(self) -> List[Dict[str, Any]]:
|
|
115
|
+
res = self._http(
|
|
116
|
+
{"method": "GET", "url": self._url("/api/v1/campaigns"), "headers": self._auth_headers()}
|
|
117
|
+
)
|
|
118
|
+
if res.status_code != 200:
|
|
119
|
+
raise DarkmoonError(_detail(res, "Failed to list campaigns"), res.status_code)
|
|
120
|
+
return _data_list(res)
|
|
121
|
+
|
|
122
|
+
# -- run ----------------------------------------------------------------
|
|
123
|
+
def run_campaign(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
124
|
+
"""Start a pentest in the background. Returns the run handle."""
|
|
125
|
+
res = self._http(
|
|
126
|
+
{
|
|
127
|
+
"method": "POST",
|
|
128
|
+
"url": self._url("/api/v1/run/campaign"),
|
|
129
|
+
"headers": self._auth_headers(),
|
|
130
|
+
"body": params,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
run_id = res.body.get("run_id") if isinstance(res.body, dict) else None
|
|
134
|
+
if res.status_code >= 400 or not run_id:
|
|
135
|
+
raise DarkmoonError(_detail(res, "Failed to start pentest run"), res.status_code)
|
|
136
|
+
return res.body
|
|
137
|
+
|
|
138
|
+
def get_run_log(self, run_id: str) -> List[Dict[str, Any]]:
|
|
139
|
+
res = self._http(
|
|
140
|
+
{
|
|
141
|
+
"method": "GET",
|
|
142
|
+
"url": self._url(f"/api/v1/run/logs/{quote(run_id, safe='')}"),
|
|
143
|
+
"headers": self._auth_headers(),
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
if res.status_code == 404:
|
|
147
|
+
return []
|
|
148
|
+
if res.status_code != 200:
|
|
149
|
+
raise DarkmoonError(_detail(res, "Failed to read run log"), res.status_code)
|
|
150
|
+
return _data_list(res)
|
|
151
|
+
|
|
152
|
+
def stop_run(self, run_id: str) -> None:
|
|
153
|
+
self._http(
|
|
154
|
+
{
|
|
155
|
+
"method": "DELETE",
|
|
156
|
+
"url": self._url(f"/api/v1/run/{quote(run_id, safe='')}/stop"),
|
|
157
|
+
"headers": self._auth_headers(),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def wait_for_run(
|
|
162
|
+
self,
|
|
163
|
+
run_id: str,
|
|
164
|
+
poll_s: float = 3.0,
|
|
165
|
+
timeout_s: float = 30 * 60,
|
|
166
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
167
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
168
|
+
) -> Dict[str, Any]:
|
|
169
|
+
"""Poll the run's JSONL log until a terminal event or the timeout.
|
|
170
|
+
|
|
171
|
+
The log is the API's single source of truth for run state: no terminal
|
|
172
|
+
event means the run is still going. Never loops forever.
|
|
173
|
+
"""
|
|
174
|
+
_sleep = sleep or time.sleep
|
|
175
|
+
start = time.monotonic()
|
|
176
|
+
seen = 0
|
|
177
|
+
while time.monotonic() - start < timeout_s:
|
|
178
|
+
events = self.get_run_log(run_id)
|
|
179
|
+
if on_event:
|
|
180
|
+
for e in events[seen:]:
|
|
181
|
+
on_event(e)
|
|
182
|
+
seen = len(events)
|
|
183
|
+
terminal = next((e for e in events if e.get("type") in _TERMINAL_TYPES), None)
|
|
184
|
+
if terminal is not None:
|
|
185
|
+
return {"events": events, "terminal": terminal, "timed_out": False}
|
|
186
|
+
_sleep(poll_s)
|
|
187
|
+
events = self.get_run_log(run_id)
|
|
188
|
+
return {"events": events, "terminal": None, "timed_out": True}
|
|
189
|
+
|
|
190
|
+
def resolve_run_campaign(
|
|
191
|
+
self, before_ids: "set[str]", target_host: str
|
|
192
|
+
) -> Optional[Dict[str, Any]]:
|
|
193
|
+
"""Resolve the campaign produced by a run.
|
|
194
|
+
|
|
195
|
+
The trigger returns a run_id, not a campaign_id - the agent mints the
|
|
196
|
+
campaign inside the run. We diff the campaign set captured before the
|
|
197
|
+
run against the set after it, preferring a new campaign whose id matches
|
|
198
|
+
the target host, and fall back to the newest campaign otherwise.
|
|
199
|
+
"""
|
|
200
|
+
campaigns = self.list_campaigns()
|
|
201
|
+
host = (target_host or "").strip().lower()
|
|
202
|
+
|
|
203
|
+
def by_date(c: Dict[str, Any]) -> str:
|
|
204
|
+
return str(c.get("date") or "")
|
|
205
|
+
|
|
206
|
+
fresh = [c for c in campaigns if c.get("id") not in before_ids]
|
|
207
|
+
if len(fresh) == 1:
|
|
208
|
+
return fresh[0]
|
|
209
|
+
if len(fresh) > 1:
|
|
210
|
+
fresh.sort(key=by_date, reverse=True)
|
|
211
|
+
if host:
|
|
212
|
+
for c in fresh:
|
|
213
|
+
if host in str(c.get("id", "")).lower():
|
|
214
|
+
return c
|
|
215
|
+
return fresh[0]
|
|
216
|
+
if not campaigns:
|
|
217
|
+
return None
|
|
218
|
+
return sorted(campaigns, key=by_date, reverse=True)[0]
|
|
219
|
+
|
|
220
|
+
# -- findings / report --------------------------------------------------
|
|
221
|
+
def get_findings(self, campaign_id: str) -> Dict[str, Any]:
|
|
222
|
+
res = self._http(
|
|
223
|
+
{
|
|
224
|
+
"method": "GET",
|
|
225
|
+
"url": self._url(
|
|
226
|
+
f"/api/v1/vulnerabilities?campaign_id={quote(campaign_id, safe='')}"
|
|
227
|
+
),
|
|
228
|
+
"headers": self._auth_headers(),
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
if res.status_code != 200:
|
|
232
|
+
raise DarkmoonError(_detail(res, "Failed to fetch findings"), res.status_code)
|
|
233
|
+
body = res.body if isinstance(res.body, dict) else {}
|
|
234
|
+
return {
|
|
235
|
+
"data": body.get("data") or [],
|
|
236
|
+
"total": body.get("total") or 0,
|
|
237
|
+
"stats": body.get("stats") or {},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def get_report(self, campaign_id: str) -> Dict[str, Any]:
|
|
241
|
+
res = self._http(
|
|
242
|
+
{
|
|
243
|
+
"method": "GET",
|
|
244
|
+
"url": self._url(f"/api/v1/campaigns/{quote(campaign_id, safe='')}/report"),
|
|
245
|
+
"headers": self._auth_headers(),
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
if res.status_code != 200:
|
|
249
|
+
raise DarkmoonError(_detail(res, "Failed to fetch report"), res.status_code)
|
|
250
|
+
body = res.body if isinstance(res.body, dict) else {}
|
|
251
|
+
return {"content": body.get("content") or "", "format": body.get("format") or "markdown"}
|
|
252
|
+
|
|
253
|
+
# -- pull requests (read-only) -----------------------------------------
|
|
254
|
+
def list_pull_requests(self, campaign_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
255
|
+
"""List fix pull requests. campaign_id is the ONLY server-side filter."""
|
|
256
|
+
q = f"?campaign_id={quote(campaign_id, safe='')}" if campaign_id else ""
|
|
257
|
+
res = self._http(
|
|
258
|
+
{
|
|
259
|
+
"method": "GET",
|
|
260
|
+
"url": self._url(f"/api/v1/pull-requests{q}"),
|
|
261
|
+
"headers": self._auth_headers(),
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
if res.status_code != 200:
|
|
265
|
+
raise DarkmoonError(_detail(res, "Failed to list pull requests"), res.status_code)
|
|
266
|
+
return _data_list(res)
|
|
267
|
+
|
|
268
|
+
def get_pull_request(self, pr_id: str) -> Dict[str, Any]:
|
|
269
|
+
res = self._http(
|
|
270
|
+
{
|
|
271
|
+
"method": "GET",
|
|
272
|
+
"url": self._url(f"/api/v1/pull-requests/{quote(pr_id, safe='')}"),
|
|
273
|
+
"headers": self._auth_headers(),
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
if res.status_code != 200:
|
|
277
|
+
raise DarkmoonError(_detail(res, f"Pull request {pr_id} not found"), res.status_code)
|
|
278
|
+
body = res.body if isinstance(res.body, dict) else {}
|
|
279
|
+
return body.get("data") or {}
|
|
280
|
+
|
|
281
|
+
def get_pull_requests_for_finding(self, vuln_id: str) -> List[Dict[str, Any]]:
|
|
282
|
+
res = self._http(
|
|
283
|
+
{
|
|
284
|
+
"method": "GET",
|
|
285
|
+
"url": self._url(f"/api/v1/pull-requests/finding/{quote(vuln_id, safe='')}"),
|
|
286
|
+
"headers": self._auth_headers(),
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
if res.status_code != 200:
|
|
290
|
+
raise DarkmoonError(
|
|
291
|
+
_detail(res, f"Failed to fetch pull requests for finding {vuln_id}"),
|
|
292
|
+
res.status_code,
|
|
293
|
+
)
|
|
294
|
+
return _data_list(res)
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def filter_pull_requests(
|
|
298
|
+
prs: List[Dict[str, Any]],
|
|
299
|
+
state: Optional[List[str]] = None,
|
|
300
|
+
provider: Optional[str] = None,
|
|
301
|
+
repository: Optional[str] = None,
|
|
302
|
+
) -> List[Dict[str, Any]]:
|
|
303
|
+
"""Client-side narrowing. The API does not filter by these fields."""
|
|
304
|
+
out = prs
|
|
305
|
+
if state:
|
|
306
|
+
want = {s.lower() for s in state}
|
|
307
|
+
out = [p for p in out if str(p.get("state", "")).lower() in want]
|
|
308
|
+
if provider:
|
|
309
|
+
p = provider.lower()
|
|
310
|
+
out = [x for x in out if str(x.get("provider", "")).lower() == p]
|
|
311
|
+
if repository:
|
|
312
|
+
r = repository.lower()
|
|
313
|
+
out = [x for x in out if r in str(x.get("repo", "")).lower()]
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def wait_for_pull_requests(
|
|
317
|
+
self,
|
|
318
|
+
campaign_id: str,
|
|
319
|
+
min_count: int = 1,
|
|
320
|
+
poll_s: float = 5.0,
|
|
321
|
+
timeout_s: float = 5 * 60,
|
|
322
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
323
|
+
) -> Dict[str, Any]:
|
|
324
|
+
_sleep = sleep or time.sleep
|
|
325
|
+
start = time.monotonic()
|
|
326
|
+
prs: List[Dict[str, Any]] = []
|
|
327
|
+
while time.monotonic() - start < timeout_s:
|
|
328
|
+
prs = self.list_pull_requests(campaign_id)
|
|
329
|
+
if len(prs) >= min_count:
|
|
330
|
+
return {"pull_requests": prs, "timed_out": False}
|
|
331
|
+
_sleep(poll_s)
|
|
332
|
+
prs = self.list_pull_requests(campaign_id)
|
|
333
|
+
return {"pull_requests": prs, "timed_out": len(prs) < min_count}
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def validate_remediation(remediate: bool, credential_id: Optional[str]) -> None:
|
|
337
|
+
"""Guard for remediation (Pro) parameters.
|
|
338
|
+
|
|
339
|
+
When remediation is enabled a credential reference (an opaque vault id,
|
|
340
|
+
NOT a token) is required, validated before any request so a run is never
|
|
341
|
+
started half-configured.
|
|
342
|
+
"""
|
|
343
|
+
if remediate and not str(credential_id or "").strip():
|
|
344
|
+
raise DarkmoonError(
|
|
345
|
+
"Remediation is enabled but no credential reference was provided. "
|
|
346
|
+
"Set the opaque Darkmoon credential reference (a vault id, not a token) "
|
|
347
|
+
"so the remediation agent can prepare a fix pull request for review."
|
|
348
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lfx.io import MessageTextInput, Output
|
|
4
|
+
from lfx.schema.data import Data
|
|
5
|
+
|
|
6
|
+
from lfx.custom.custom_component.component import Component
|
|
7
|
+
|
|
8
|
+
from lfx_darkmoon.components.darkmoon._base import DarkmoonClientMixin, connection_inputs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DarkmoonGetFindingsComponent(DarkmoonClientMixin, Component):
|
|
12
|
+
"""Return the vulnerabilities and aggregated stats for a Darkmoon campaign."""
|
|
13
|
+
|
|
14
|
+
display_name = "Darkmoon Get Findings"
|
|
15
|
+
description = "Return the vulnerabilities and aggregated stats for a Darkmoon campaign id."
|
|
16
|
+
icon = "Shield"
|
|
17
|
+
name = "DarkmoonGetFindings"
|
|
18
|
+
|
|
19
|
+
inputs = [
|
|
20
|
+
*connection_inputs(),
|
|
21
|
+
MessageTextInput(
|
|
22
|
+
name="campaign_id",
|
|
23
|
+
display_name="Campaign id",
|
|
24
|
+
info="The Darkmoon campaign id, e.g. camp_20260922_abc123.",
|
|
25
|
+
required=True,
|
|
26
|
+
),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
outputs = [Output(display_name="Findings", name="findings", method="get_findings_action")]
|
|
30
|
+
|
|
31
|
+
def get_findings_action(self) -> Data:
|
|
32
|
+
campaign_id = str(self.campaign_id or "").strip()
|
|
33
|
+
if not campaign_id:
|
|
34
|
+
raise ValueError("A campaign id is required.")
|
|
35
|
+
findings = self._client().get_findings(campaign_id)
|
|
36
|
+
data = Data(
|
|
37
|
+
data={
|
|
38
|
+
"campaign_id": campaign_id,
|
|
39
|
+
"total": findings["total"],
|
|
40
|
+
"stats": findings["stats"],
|
|
41
|
+
"findings": findings["data"],
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
self.status = data
|
|
45
|
+
return data
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lfx.io import Output
|
|
4
|
+
from lfx.schema.data import Data
|
|
5
|
+
|
|
6
|
+
from lfx.custom.custom_component.component import Component
|
|
7
|
+
|
|
8
|
+
from lfx_darkmoon.components.darkmoon._base import DarkmoonClientMixin, connection_inputs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DarkmoonListCampaignsComponent(DarkmoonClientMixin, Component):
|
|
12
|
+
"""Return the campaigns visible to the authenticated dashboard user."""
|
|
13
|
+
|
|
14
|
+
display_name = "Darkmoon List Campaigns"
|
|
15
|
+
description = "List the Darkmoon campaigns visible to the authenticated dashboard user."
|
|
16
|
+
icon = "Shield"
|
|
17
|
+
name = "DarkmoonListCampaigns"
|
|
18
|
+
|
|
19
|
+
inputs = [*connection_inputs()]
|
|
20
|
+
|
|
21
|
+
outputs = [Output(display_name="Campaigns", name="campaigns", method="list_campaigns_action")]
|
|
22
|
+
|
|
23
|
+
def list_campaigns_action(self) -> Data:
|
|
24
|
+
campaigns = self._client().list_campaigns()
|
|
25
|
+
data = Data(data={"total": len(campaigns), "campaigns": campaigns})
|
|
26
|
+
self.status = data
|
|
27
|
+
return data
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lfx.io import DropdownInput, MessageTextInput, Output
|
|
4
|
+
from lfx.schema.data import Data
|
|
5
|
+
|
|
6
|
+
from lfx.custom.custom_component.component import Component
|
|
7
|
+
|
|
8
|
+
from lfx_darkmoon.components.darkmoon._base import DarkmoonClientMixin, connection_inputs
|
|
9
|
+
from lfx_darkmoon.components.darkmoon.darkmoon_client import DarkmoonClient, PR_STATES
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DarkmoonListPullRequestsComponent(DarkmoonClientMixin, Component):
|
|
13
|
+
"""Return the fix pull requests Darkmoon prepared (read-only).
|
|
14
|
+
|
|
15
|
+
Pull requests are prepared by the paid Pro remediation feature and left for a
|
|
16
|
+
human to review and merge. This component only reads them; it never merges.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
display_name = "Darkmoon List Pull Requests"
|
|
20
|
+
description = "List the fix pull requests Darkmoon prepared (read-only)."
|
|
21
|
+
icon = "Shield"
|
|
22
|
+
name = "DarkmoonListPullRequests"
|
|
23
|
+
|
|
24
|
+
inputs = [
|
|
25
|
+
*connection_inputs(),
|
|
26
|
+
MessageTextInput(
|
|
27
|
+
name="campaign_id",
|
|
28
|
+
display_name="Campaign id",
|
|
29
|
+
info="Optional campaign id to scope the pull requests to (the only server-side filter).",
|
|
30
|
+
advanced=True,
|
|
31
|
+
),
|
|
32
|
+
DropdownInput(
|
|
33
|
+
name="state",
|
|
34
|
+
display_name="State",
|
|
35
|
+
options=["", *PR_STATES],
|
|
36
|
+
value="",
|
|
37
|
+
advanced=True,
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
outputs = [Output(display_name="Pull Requests", name="pull_requests", method="list_pull_requests_action")]
|
|
42
|
+
|
|
43
|
+
def list_pull_requests_action(self) -> Data:
|
|
44
|
+
campaign_id = str(self.campaign_id or "").strip() or None
|
|
45
|
+
client = self._client()
|
|
46
|
+
prs = client.list_pull_requests(campaign_id)
|
|
47
|
+
state = str(self.state or "").strip()
|
|
48
|
+
if state:
|
|
49
|
+
prs = DarkmoonClient.filter_pull_requests(prs, state=[state])
|
|
50
|
+
data = Data(data={"total": len(prs), "pull_requests": prs})
|
|
51
|
+
self.status = data
|
|
52
|
+
return data
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lfx.io import BoolInput, DropdownInput, MessageTextInput, Output, SecretStrInput, StrInput
|
|
4
|
+
from lfx.schema.data import Data
|
|
5
|
+
|
|
6
|
+
from lfx.custom.custom_component.component import Component
|
|
7
|
+
|
|
8
|
+
from lfx_darkmoon.components.darkmoon._base import DarkmoonClientMixin, connection_inputs
|
|
9
|
+
from lfx_darkmoon.components.darkmoon.darkmoon_client import DarkmoonClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _split_csv(value: str) -> list[str]:
|
|
13
|
+
return [p.strip() for p in str(value or "").split(",") if p.strip()]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DarkmoonRunPentestComponent(DarkmoonClientMixin, Component):
|
|
17
|
+
"""Trigger a Darkmoon pentest against an authorised target and return findings.
|
|
18
|
+
|
|
19
|
+
Only run assessments against systems you own or have explicit written
|
|
20
|
+
authorisation to test. Findings can include false positives and must be
|
|
21
|
+
reviewed by a qualified human. This component never merges a pull request;
|
|
22
|
+
optional, credential-gated remediation is a paid Pro feature that only
|
|
23
|
+
prepares fixes for review.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
display_name = "Darkmoon Run Pentest"
|
|
27
|
+
description = "Start a Darkmoon AI pentest against an authorised target and return the findings."
|
|
28
|
+
icon = "Shield"
|
|
29
|
+
name = "DarkmoonRunPentest"
|
|
30
|
+
|
|
31
|
+
inputs = [
|
|
32
|
+
*connection_inputs(),
|
|
33
|
+
MessageTextInput(
|
|
34
|
+
name="target",
|
|
35
|
+
display_name="Target",
|
|
36
|
+
info="The host, URL or scope to assess. Only targets you are authorised to test.",
|
|
37
|
+
required=True,
|
|
38
|
+
),
|
|
39
|
+
BoolInput(
|
|
40
|
+
name="wait_for_completion",
|
|
41
|
+
display_name="Wait for completion",
|
|
42
|
+
info="Wait for the run to finish and return findings; if off, return the run id.",
|
|
43
|
+
value=True,
|
|
44
|
+
),
|
|
45
|
+
MessageTextInput(name="program", display_name="Program / rules of engagement", advanced=True),
|
|
46
|
+
MessageTextInput(
|
|
47
|
+
name="focus",
|
|
48
|
+
display_name="Focus areas",
|
|
49
|
+
info='Optional comma-separated focus areas, e.g. "authentication, injection".',
|
|
50
|
+
advanced=True,
|
|
51
|
+
),
|
|
52
|
+
DropdownInput(
|
|
53
|
+
name="severity",
|
|
54
|
+
display_name="Minimum severity",
|
|
55
|
+
options=["", "info", "low", "medium", "high", "critical"],
|
|
56
|
+
value="",
|
|
57
|
+
advanced=True,
|
|
58
|
+
),
|
|
59
|
+
BoolInput(
|
|
60
|
+
name="remediate",
|
|
61
|
+
display_name="Prepare fix pull requests (Pro)",
|
|
62
|
+
info="Paid Pro feature. Prepares fix pull requests for human review during the run; never merges. Requires a credential reference.",
|
|
63
|
+
value=False,
|
|
64
|
+
advanced=True,
|
|
65
|
+
),
|
|
66
|
+
SecretStrInput(
|
|
67
|
+
name="credential_id",
|
|
68
|
+
display_name="Credential reference (Pro remediation)",
|
|
69
|
+
info="Opaque Darkmoon vault credential reference (not a token). Required only when remediation is on.",
|
|
70
|
+
advanced=True,
|
|
71
|
+
),
|
|
72
|
+
StrInput(name="git_repo", display_name="Git repository (Pro remediation)", advanced=True),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
outputs = [Output(display_name="Result", name="result", method="run_pentest_action")]
|
|
76
|
+
|
|
77
|
+
def run_pentest_action(self) -> Data:
|
|
78
|
+
target = str(self.target or "").strip()
|
|
79
|
+
if not target:
|
|
80
|
+
raise ValueError("A target is required (a host, URL or scope you are authorised to test).")
|
|
81
|
+
remediate = bool(self.remediate)
|
|
82
|
+
credential_id = str(self.credential_id or "").strip() or None
|
|
83
|
+
DarkmoonClient.validate_remediation(remediate, credential_id)
|
|
84
|
+
|
|
85
|
+
params: dict = {"target": target}
|
|
86
|
+
if str(self.program or "").strip():
|
|
87
|
+
params["program"] = str(self.program).strip()
|
|
88
|
+
areas = _split_csv(self.focus)
|
|
89
|
+
if areas:
|
|
90
|
+
params["focus"] = areas
|
|
91
|
+
if str(self.severity or "").strip():
|
|
92
|
+
params["severity"] = str(self.severity).strip()
|
|
93
|
+
if remediate:
|
|
94
|
+
params["remediate"] = True
|
|
95
|
+
params["credential_id"] = credential_id
|
|
96
|
+
if str(self.git_repo or "").strip():
|
|
97
|
+
params["git_repo"] = str(self.git_repo).strip()
|
|
98
|
+
|
|
99
|
+
client = self._client()
|
|
100
|
+
before = {c.get("id") for c in client.list_campaigns()}
|
|
101
|
+
handle = client.run_campaign(params)
|
|
102
|
+
run_id = handle.get("run_id")
|
|
103
|
+
|
|
104
|
+
if not bool(self.wait_for_completion):
|
|
105
|
+
data = Data(data={"status": "started", "run_id": run_id, "target": target})
|
|
106
|
+
self.status = data
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
result = client.wait_for_run(run_id)
|
|
110
|
+
campaign = client.resolve_run_campaign(before, target)
|
|
111
|
+
campaign_id = campaign.get("id") if campaign else None
|
|
112
|
+
out = {
|
|
113
|
+
"run_id": run_id,
|
|
114
|
+
"campaign_id": campaign_id,
|
|
115
|
+
"timed_out": result["timed_out"],
|
|
116
|
+
"findings": [],
|
|
117
|
+
"stats": {},
|
|
118
|
+
"total": 0,
|
|
119
|
+
}
|
|
120
|
+
if campaign_id:
|
|
121
|
+
findings = client.get_findings(campaign_id)
|
|
122
|
+
out["findings"] = findings["data"]
|
|
123
|
+
out["stats"] = findings["stats"]
|
|
124
|
+
out["total"] = findings["total"]
|
|
125
|
+
data = Data(data=out)
|
|
126
|
+
self.status = data
|
|
127
|
+
return data
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
requests-based transport + client factory for the Langflow Darkmoon components.
|
|
3
|
+
|
|
4
|
+
Kept out of ``darkmoon_client`` so the client stays dependency free and
|
|
5
|
+
unit-testable without ``requests``. The transport never raises on a non-2xx
|
|
6
|
+
status: it returns the real status and parsed body so the client surfaces the
|
|
7
|
+
API's own ``detail`` rather than a local secret.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
from lfx_darkmoon.components.darkmoon.darkmoon_client import DarkmoonClient, HttpResponse
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def requests_transport(opts: Dict[str, Any]) -> HttpResponse:
|
|
19
|
+
import requests # imported lazily so tests need no network stack
|
|
20
|
+
|
|
21
|
+
method = str(opts.get("method", "GET")).upper()
|
|
22
|
+
url = opts["url"]
|
|
23
|
+
headers = opts.get("headers") or {}
|
|
24
|
+
body = opts.get("body")
|
|
25
|
+
try:
|
|
26
|
+
resp = requests.request(
|
|
27
|
+
method,
|
|
28
|
+
url,
|
|
29
|
+
headers=headers,
|
|
30
|
+
json=body if body is not None else None,
|
|
31
|
+
timeout=DEFAULT_TIMEOUT,
|
|
32
|
+
)
|
|
33
|
+
except requests.exceptions.RequestException as exc:
|
|
34
|
+
return HttpResponse(status_code=0, body={"detail": f"Request to Darkmoon failed: {exc}"})
|
|
35
|
+
try:
|
|
36
|
+
parsed: Any = resp.json()
|
|
37
|
+
except ValueError:
|
|
38
|
+
parsed = resp.text
|
|
39
|
+
return HttpResponse(status_code=resp.status_code, body=parsed)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def logged_in_client(base_url: str, username: str, password: str) -> DarkmoonClient:
|
|
43
|
+
"""Build a DarkmoonClient over the requests transport and authenticate it."""
|
|
44
|
+
client = DarkmoonClient(base_url, requests_transport)
|
|
45
|
+
client.login(username, password)
|
|
46
|
+
return client
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schemas.langflow.org/extension/v1.json",
|
|
3
|
+
"id": "lfx-darkmoon",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"name": "Darkmoon",
|
|
6
|
+
"description": "Trigger a Darkmoon autonomous AI penetration test against an authorised target, pull findings, and review the fix pull requests Darkmoon prepares, over a self-hosted Darkmoon Dashboard API. Only assess systems you own or have explicit written authorisation to test; findings can include false positives and must be reviewed by a qualified human.",
|
|
7
|
+
"lfx": {
|
|
8
|
+
"compat": ["1"]
|
|
9
|
+
},
|
|
10
|
+
"capabilities": {
|
|
11
|
+
"requiresCredentials": true
|
|
12
|
+
},
|
|
13
|
+
"bundles": [
|
|
14
|
+
{
|
|
15
|
+
"name": "darkmoon",
|
|
16
|
+
"path": "components/darkmoon",
|
|
17
|
+
"display_name": "Darkmoon",
|
|
18
|
+
"icon": "Shield"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|