Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ Results are stored in `output/` with timestamps. Notifications can be sent via

---

## Real-time dashboard

Start the CLI with a WebSocket port to stream live results:

```bash
python main.py example.com --pipeline --ws-port 8765
```

Then install and launch the React interface:

```bash
cd ui && npm install
npm start
```

Open <http://localhost:3000> to view the dashboard with a live terminal feed
and 3D recon graph.

---

## Plugins

Drop Python files into a `plugins/` directory or set the
Expand Down
28 changes: 27 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from utils.deps import DependencyError, check_dependencies
from utils.plugins import load_plugins
from distributed import DistributedOrchestrator
from utils import realtime

load_plugins()

Expand Down Expand Up @@ -110,6 +111,8 @@ def _run_host_tool(tool_cls: type[Module], host_list: List[str], name: str) -> L
logger.error("%s error: %s", name, exc)
continue
local.extend(res)
for f in res:
realtime.publish(f.asdict())
if name == "nmap":
xml_path = res[0].data.get("xml")
if xml_path:
Expand Down Expand Up @@ -149,6 +152,8 @@ def _run_host_tool(tool_cls: type[Module], host_list: List[str], name: str) -> L
try:
res = fut.result(timeout=getattr(cls, "TIMEOUT", 60))
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
except Exception as exc: # noqa: BLE001
logger.error("%s error: %s", name, exc)
if show_summary:
Expand All @@ -166,11 +171,15 @@ def _run_host_tool(tool_cls: type[Module], host_list: List[str], name: str) -> L
if use_pipeline and tool_name == "subfinder":
res = tool_cls().run(target)
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
hosts = [f.data.get("host") for f in res if f.data.get("host")]
summary["subdomains"] += len(res)
elif use_pipeline and tool_name == "httpx":
res = tool_cls().run("\n".join(hosts))
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
urls = [f.data.get("url") for f in res if f.data.get("url")]
hosts = [
f.data.get("host") or f.data.get("ip")
Expand All @@ -185,6 +194,8 @@ def _run_host_tool(tool_cls: type[Module], host_list: List[str], name: str) -> L
continue
res = tool_cls().run("\n".join(urls))
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
summary["nuclei"] += len(res)
elif use_pipeline and tool_name in {"nmap", "testssl"}:
if prev_state is not None and not new_urls:
Expand All @@ -204,12 +215,16 @@ def _run_host_tool(tool_cls: type[Module], host_list: List[str], name: str) -> L
for fut, name in futs.items():
res = fut.result()
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
continue
else:
processed.add(tool_name)
for h in hosts:
res = tool_cls().run(h)
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
if tool_name == "nmap":
xml_path = res[0].data.get("xml")
if xml_path:
Expand All @@ -219,7 +234,10 @@ def _run_host_tool(tool_cls: type[Module], host_list: List[str], name: str) -> L
if json_path:
summary["ssl_issues"] += _count_testssl_issues(json_path)
else:
findings.extend(tool_cls().run(target))
res = tool_cls().run(target)
findings.extend(res)
for f in res:
realtime.publish(f.asdict())
except Exception as exc: # noqa: BLE001
logger.error("%s error: %s", tool_name, exc)

Expand Down Expand Up @@ -359,8 +377,16 @@ def cli() -> None:
"--runners",
help="Comma-separated list of runner URLs (default: $PENTEST_TOOLKIT_RUNNERS)",
)
parser.add_argument(
"--ws-port",
type=int,
help="Start a WebSocket server on the given port for live results",
)
args = parser.parse_args()

if args.ws_port:
realtime.start(args.ws_port)

runners: List[str] = []
if args.runners:
runners = [r.strip() for r in args.runners.split(',') if r.strip()]
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"rich>=13",
"weasyprint>=61",
"requests>=2",
"websockets>=12",
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pytest>=8
weasyprint>=61
requests>=2
markdown>=3
websockets>=12
3 changes: 3 additions & 0 deletions ui/babel.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
12 changes: 12 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Pentest Toolkit Dashboard</title>
<style>body{margin:0;background:#000;color:#0f0;}</style>
</head>
<body>
<div id="root"></div>
<script src="dist/bundle.js"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "pentest-toolkit-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-force-graph": "^1.47.0",
"three": "^0.163.0",
"react-three-fiber": "^9.0.0"
},
"devDependencies": {
"@babel/core": "^7.24.5",
"@babel/preset-env": "^7.24.5",
"@babel/preset-react": "^7.24.5",
"babel-loader": "^9.1.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
}
36 changes: 36 additions & 0 deletions ui/src/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { useEffect, useState } from 'react';
import ForceGraph3D from 'react-force-graph';

export default function App() {
const [graph, setGraph] = useState({ nodes: [], links: [] });
const [feed, setFeed] = useState([]);

useEffect(() => {
const ws = new WebSocket('ws://localhost:8765');
ws.onmessage = e => {
const msg = JSON.parse(e.data);
setFeed(f => [...f, msg]);
if (msg.type === 'subdomain') {
setGraph(g => ({ ...g, nodes: [...g.nodes, { id: msg.host }] }));
}
if (msg.type === 'httpx') {
setGraph(g => ({
nodes: g.nodes,
links: [...g.links, { source: msg.host, target: msg.url }]
}));
}
};
return () => ws.close();
}, []);

return (
<div style={{ display: 'flex', height: '100vh' }}>
<div style={{ width: '30%', overflow: 'auto', background: '#111', color: '#0f0', padding: '0.5rem' }}>
{feed.map((f, i) => <div key={i}>{JSON.stringify(f)}</div>)}
</div>
<div style={{ flex: 1 }}>
<ForceGraph3D graphData={graph} />
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions ui/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

const root = createRoot(document.getElementById('root'));
root.render(<App />);
27 changes: 27 additions & 0 deletions ui/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const path = require('path');

module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
}
]
},
resolve: {
extensions: ['.js', '.jsx']
},
devServer: {
static: './',
port: 3000
}
};
52 changes: 52 additions & 0 deletions utils/realtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import asyncio
import json
import threading
from typing import Any, Dict, Set

import websockets
from websockets.server import WebSocketServerProtocol

clients: Set[WebSocketServerProtocol] = set()
loop: asyncio.AbstractEventLoop | None = None
started = False

async def _handler(ws: WebSocketServerProtocol):
clients.add(ws)
try:
async for _ in ws:
pass
finally:
clients.discard(ws)

async def _broadcast(msg: Dict[str, Any]) -> None:
dead = []
for ws in clients:
try:
await ws.send(json.dumps(msg))
except Exception:
dead.append(ws)
for ws in dead:
clients.discard(ws)


def start(port: int = 8765) -> None:
"""Launch the WebSocket server in a background thread."""
global loop, started
if started:
return
loop = asyncio.new_event_loop()
started = True

def _run() -> None:
asyncio.set_event_loop(loop)
server = websockets.serve(_handler, "0.0.0.0", port)
loop.run_until_complete(server)
loop.run_forever()

threading.Thread(target=_run, daemon=True).start()


def publish(msg: Dict[str, Any]) -> None:
"""Queue a message to all connected clients."""
if started and loop:
loop.call_soon_threadsafe(asyncio.create_task, _broadcast(msg))