@burtthecoder/mcp-dnstwist 1.0.5 → 1.0.7
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.
- package/README.md +13 -3
- package/build/index.js +74 -9
- package/build/validate.js +10 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ This tool is designed for legitimate security research purposes. Please:
|
|
|
17
17
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
|
-
- Node.js (
|
|
20
|
+
- Node.js (v20.3 or later)
|
|
21
21
|
- Docker
|
|
22
22
|
- macOS, Linux, or Windows with Docker Desktop installed
|
|
23
23
|
|
|
@@ -100,8 +100,8 @@ npm run build
|
|
|
100
100
|
- Description: Generate and analyze domain permutations to detect potential typosquatting, phishing, and brand impersonation
|
|
101
101
|
- Parameters:
|
|
102
102
|
* `domain` (required): Domain name to analyze (e.g., example.com)
|
|
103
|
-
* `nameservers` (optional, default: "1.1.1.1"): Comma-separated
|
|
104
|
-
* `threads` (optional, default: 50): Number of threads for parallel processing
|
|
103
|
+
* `nameservers` (optional, default: "1.1.1.1"): Comma-separated DNS servers, up to 10. Each must be an IPv4 address or a DNS-over-HTTPS URL such as `https://cloudflare-dns.com/dns-query` (the forms dnstwist accepts). DNS-over-HTTPS is much slower: a DoH-only scan can take over 10 minutes, so include an IPv4 resolver unless you need DoH
|
|
104
|
+
* `threads` (optional, default: 50): Number of threads for parallel processing (1-100)
|
|
105
105
|
* `format` (optional, default: "json"): Output format (json, csv, list)
|
|
106
106
|
* `registered_only` (optional, default: true): Show only registered domains
|
|
107
107
|
* `mxcheck` (optional, default: true): Check for MX records
|
|
@@ -121,6 +121,14 @@ Example:
|
|
|
121
121
|
}
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
| Variable | Default | Description |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `DNSTWIST_TIMEOUT_SECONDS` | `600` | Longest a scan may run. When it's reached, or when the client cancels the call, the scan's container is stopped and removed. |
|
|
129
|
+
|
|
130
|
+
Set it in your client's `env` block, for example `"env": { "DNSTWIST_TIMEOUT_SECONDS": "900" }`.
|
|
131
|
+
|
|
124
132
|
## Troubleshooting
|
|
125
133
|
|
|
126
134
|
### Docker Issues
|
|
@@ -153,6 +161,8 @@ docker ps
|
|
|
153
161
|
|
|
154
162
|
## Error Messages
|
|
155
163
|
|
|
164
|
+
- "dnstwist did not finish within N seconds": the scan hit `DNSTWIST_TIMEOUT_SECONDS`. Use an IPv4 nameserver (DNS-over-HTTPS is slow), turn off `ssdeep` or `banners`, or raise the limit
|
|
165
|
+
|
|
156
166
|
- "Docker is not installed or not running": Install Docker and start the Docker daemon
|
|
157
167
|
- "Failed to parse dnstwist output": Check if the domain is valid and the format is correct
|
|
158
168
|
- "Error executing dnstwist": Check Docker logs and ensure proper permissions
|
package/build/index.js
CHANGED
|
@@ -3,32 +3,58 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { execFile } from 'child_process';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
6
7
|
import { promisify } from 'util';
|
|
7
8
|
import { validateFuzzArgs } from './validate.js';
|
|
8
9
|
// execFile runs docker directly with an argument array. No shell is involved,
|
|
9
10
|
// so tool input can never be interpreted as shell syntax (CWE-78, issue #13).
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
const DOCKER_IMAGE = 'elceef/dnstwist';
|
|
13
|
+
// A scan that runs longer than this is stopped and its container removed.
|
|
14
|
+
// Slow resolvers (DNS-over-HTTPS) and --ssdeep/--banners can take many minutes.
|
|
15
|
+
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
16
|
+
function scanTimeoutSeconds() {
|
|
17
|
+
const raw = process.env.DNSTWIST_TIMEOUT_SECONDS;
|
|
18
|
+
if (raw === undefined || raw === '')
|
|
19
|
+
return DEFAULT_TIMEOUT_SECONDS;
|
|
20
|
+
const seconds = Number(raw);
|
|
21
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 86400) {
|
|
22
|
+
console.error(`DNSTWIST_TIMEOUT_SECONDS must be an integer from 1 to 86400, got ${JSON.stringify(raw)}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
return seconds;
|
|
26
|
+
}
|
|
27
|
+
const TIMEOUT_SECONDS = scanTimeoutSeconds();
|
|
12
28
|
class DnstwistServer {
|
|
13
29
|
constructor() {
|
|
30
|
+
// Scans in progress: container name -> controller that stops its docker run.
|
|
31
|
+
this.runningContainers = new Map();
|
|
14
32
|
this.server = new Server({ name: 'dnstwist-server', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
15
33
|
this.setupToolHandlers();
|
|
16
34
|
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
35
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
36
|
+
process.on(signal, async () => {
|
|
37
|
+
// Don't leave scans running after the server is gone: stop each
|
|
38
|
+
// in-flight docker run first, then remove its container.
|
|
39
|
+
for (const controller of this.runningContainers.values())
|
|
40
|
+
controller.abort();
|
|
41
|
+
await Promise.all([...this.runningContainers.keys()].map((name) => this.removeContainer(name)));
|
|
42
|
+
await this.server.close();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
21
46
|
// Trigger setup immediately
|
|
22
47
|
this.ensureSetup().catch(error => {
|
|
23
48
|
console.error('Failed to setup dnstwist:', error);
|
|
24
49
|
process.exit(1);
|
|
25
50
|
});
|
|
26
51
|
}
|
|
27
|
-
async execCommand(file, args) {
|
|
52
|
+
async execCommand(file, args, options = {}) {
|
|
28
53
|
console.error('Executing:', file, JSON.stringify(args));
|
|
29
54
|
try {
|
|
30
55
|
const result = await execFileAsync(file, args, {
|
|
31
|
-
maxBuffer: 10 * 1024 * 1024
|
|
56
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
57
|
+
...options
|
|
32
58
|
});
|
|
33
59
|
console.error('Command output:', result.stdout);
|
|
34
60
|
if (result.stderr)
|
|
@@ -40,6 +66,22 @@ class DnstwistServer {
|
|
|
40
66
|
throw error;
|
|
41
67
|
}
|
|
42
68
|
}
|
|
69
|
+
// Killing the docker CLI doesn't stop the container it started, so a scan
|
|
70
|
+
// that times out or is cancelled is removed by name. Right after the CLI
|
|
71
|
+
// starts, the daemon may not have created the container yet ("No such
|
|
72
|
+
// container"), so keep trying for a few seconds until a removal succeeds.
|
|
73
|
+
async removeContainer(name, attempts = 10, delayMs = 500) {
|
|
74
|
+
for (let i = 0; i < attempts; i++) {
|
|
75
|
+
try {
|
|
76
|
+
await execFileAsync('docker', ['rm', '-f', name]);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
console.error(`Could not confirm removal of container ${name}`);
|
|
84
|
+
}
|
|
43
85
|
async ensureSetup() {
|
|
44
86
|
try {
|
|
45
87
|
console.error('Checking Docker...');
|
|
@@ -80,7 +122,7 @@ class DnstwistServer {
|
|
|
80
122
|
},
|
|
81
123
|
nameservers: {
|
|
82
124
|
type: 'string',
|
|
83
|
-
description: 'Comma-separated
|
|
125
|
+
description: 'Comma-separated DNS servers: IPv4 addresses or DNS-over-HTTPS URLs such as https://cloudflare-dns.com/dns-query (default: 1.1.1.1)',
|
|
84
126
|
default: '1.1.1.1'
|
|
85
127
|
},
|
|
86
128
|
threads: {
|
|
@@ -122,7 +164,7 @@ class DnstwistServer {
|
|
|
122
164
|
}
|
|
123
165
|
]
|
|
124
166
|
}));
|
|
125
|
-
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
167
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
126
168
|
try {
|
|
127
169
|
await this.ensureSetup();
|
|
128
170
|
if (request.params.name !== 'fuzz_domain') {
|
|
@@ -147,7 +189,30 @@ class DnstwistServer {
|
|
|
147
189
|
if (banners)
|
|
148
190
|
args.push('-b');
|
|
149
191
|
// Run dnstwist in Docker
|
|
150
|
-
const
|
|
192
|
+
const container = `mcp-dnstwist-${randomUUID()}`;
|
|
193
|
+
const stop = new AbortController();
|
|
194
|
+
this.runningContainers.set(container, stop);
|
|
195
|
+
let stdout;
|
|
196
|
+
try {
|
|
197
|
+
({ stdout } = await this.execCommand('docker', ['run', '--rm', '--name', container, DOCKER_IMAGE, ...args], { timeout: TIMEOUT_SECONDS * 1000, signal: AbortSignal.any([extra.signal, stop.signal]) }));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const stopped = extra.signal.aborted || stop.signal.aborted;
|
|
201
|
+
const timedOut = Boolean(error.killed) && !stopped;
|
|
202
|
+
// If dnstwist exited on its own, --rm already removed the container.
|
|
203
|
+
if (timedOut || stopped)
|
|
204
|
+
await this.removeContainer(container);
|
|
205
|
+
if (timedOut) {
|
|
206
|
+
const unit = TIMEOUT_SECONDS === 1 ? 'second' : 'seconds';
|
|
207
|
+
throw new Error(`dnstwist did not finish within ${TIMEOUT_SECONDS} ${unit} and was stopped. ` +
|
|
208
|
+
'Use an IPv4 nameserver (DNS-over-HTTPS is slow), turn off ssdeep or banners, ' +
|
|
209
|
+
'or raise DNSTWIST_TIMEOUT_SECONDS.');
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
this.runningContainers.delete(container);
|
|
215
|
+
}
|
|
151
216
|
// csv and list output are returned as dnstwist printed them.
|
|
152
217
|
if (format !== 'json') {
|
|
153
218
|
return { content: [{ type: 'text', text: stdout }] };
|
package/build/validate.js
CHANGED
|
@@ -7,6 +7,8 @@ import { domainToASCII } from 'url';
|
|
|
7
7
|
const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/i;
|
|
8
8
|
const FORMATS = ['json', 'csv', 'list'];
|
|
9
9
|
const MAX_NAMESERVERS = 10;
|
|
10
|
+
// The DNS-over-HTTPS form dnstwist accepts (dnstwist.py --nameservers parsing).
|
|
11
|
+
const DOH = /^https:\/\/([a-z0-9.-]{4,253})\/dns-query$/;
|
|
10
12
|
function invalid(message) {
|
|
11
13
|
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for fuzz_domain: ${message}`);
|
|
12
14
|
}
|
|
@@ -42,12 +44,18 @@ export function validateFuzzArgs(args) {
|
|
|
42
44
|
const rawNameservers = a.nameservers === undefined ? '1.1.1.1' : a.nameservers;
|
|
43
45
|
if (typeof rawNameservers !== 'string')
|
|
44
46
|
invalid('nameservers must be a string');
|
|
47
|
+
// dnstwist only accepts IPv4 addresses and https://<host>/dns-query URLs, so
|
|
48
|
+
// anything else would fail inside the container anyway.
|
|
45
49
|
const servers = rawNameservers.split(',').map((ns) => {
|
|
46
50
|
const trimmed = ns.trim();
|
|
47
|
-
|
|
51
|
+
if (isIP(trimmed) === 4)
|
|
52
|
+
return trimmed;
|
|
53
|
+
const doh = trimmed.match(DOH);
|
|
54
|
+
return doh && (isIP(doh[1]) === 4 || HOSTNAME.test(doh[1])) ? trimmed : null;
|
|
48
55
|
});
|
|
49
56
|
if (servers.length > MAX_NAMESERVERS || servers.some((ns) => !ns)) {
|
|
50
|
-
invalid(`nameservers must be up to ${MAX_NAMESERVERS} comma-separated
|
|
57
|
+
invalid(`nameservers must be up to ${MAX_NAMESERVERS} comma-separated IPv4 addresses ` +
|
|
58
|
+
'or DNS-over-HTTPS URLs like https://cloudflare-dns.com/dns-query');
|
|
51
59
|
}
|
|
52
60
|
const threads = a.threads === undefined ? 50 : a.threads;
|
|
53
61
|
if (typeof threads !== 'number' || !Number.isInteger(threads) || threads < 1 || threads > 100) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burtthecoder/mcp-dnstwist",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "MCP server for dnstwist - DNS fuzzing to detect typosquatting, phishing and corporate espionage",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
"author": "burtmacklin",
|
|
43
43
|
"license": "MIT",
|
|
44
44
|
"engines": {
|
|
45
|
-
"node": ">=20"
|
|
45
|
+
"node": ">=20.3"
|
|
46
46
|
}
|
|
47
47
|
}
|