@burtthecoder/mcp-dnstwist 1.0.6 → 1.1.0
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 +97 -145
- package/build/index.js +210 -36
- package/build/validate.js +28 -1
- package/package.json +19 -6
package/README.md
CHANGED
|
@@ -1,171 +1,123 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
<a href="https://
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img alt="MCP dnstwist: typosquatting detection for AI agents. Find lookalike, homoglyph and phishing domains with dnstwist over the Model Context Protocol." src="docs/assets/banner.webp" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@burtthecoder/mcp-dnstwist"><img alt="npm version" src="https://img.shields.io/npm/v/@burtthecoder/mcp-dnstwist"></a>
|
|
7
|
+
<a href="https://github.com/w0h1v/mcp-dnstwist/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/w0h1v/mcp-dnstwist/actions/workflows/ci.yml/badge.svg"></a>
|
|
8
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
9
|
+
<img alt="Node 20.3 or newer" src="https://img.shields.io/badge/node-%E2%89%A520.3-339933">
|
|
10
|
+
<a href="https://registry.modelcontextprotocol.io"><img alt="MCP Registry" src="https://img.shields.io/badge/MCP-Registry-7c3aed"></a>
|
|
11
|
+
<a href="https://smithery.ai/server/@burtthecoder/mcp-dnstwist"><img alt="Smithery" src="https://img.shields.io/badge/Smithery-listed-ff5601"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="#quickstart">Quickstart</a> ·
|
|
16
|
+
<a href="#the-tool">Tool</a> ·
|
|
17
|
+
<a href="#security">Security</a> ·
|
|
18
|
+
<a href="#troubleshooting">Troubleshooting</a> ·
|
|
19
|
+
<a href="https://w0h1v.github.io/mcp-dnstwist/">Docs</a>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
**mcp-dnstwist** is an [MCP](https://modelcontextprotocol.io) server that lets AI agents run [dnstwist](https://github.com/elceef/dnstwist) to find domains that imitate yours: typos, homoglyphs, bit flips, swapped TLDs and more. For each registered lookalike it can report DNS records, whether its mail server could intercept your email, WHOIS registration details, and how closely its web page copies yours.
|
|
23
|
+
|
|
24
|
+
> [!NOTE]
|
|
25
|
+
> Independent project, not affiliated with dnstwist. Only scan domains you own or are authorized to assess.
|
|
26
|
+
|
|
27
|
+
> [!IMPORTANT]
|
|
28
|
+
> Versions up to 1.0.4 have a command injection vulnerability ([GHSA-p4jr-h3rj-2rw4](https://github.com/w0h1v/mcp-dnstwist/security/advisories/GHSA-p4jr-h3rj-2rw4)) and are deprecated. Use 1.0.5 or later; `npx -y @burtthecoder/mcp-dnstwist` always fetches the latest.
|
|
29
|
+
|
|
30
|
+
## Highlights
|
|
31
|
+
|
|
32
|
+
- **Lookalike discovery.** All of dnstwist's permutation algorithms, or only the ones you pick.
|
|
33
|
+
- **Threat signals.** DNS records, open mail relays that could intercept email (`mxcheck`), WHOIS creation date and registrar, HTTP and SMTP banners, and web-page similarity (ssdeep or TLSH) to spot cloned phishing sites.
|
|
34
|
+
- **Runs your way.** A local dnstwist install, the official dnstwist Docker image, or a self-contained mcp-dnstwist image.
|
|
35
|
+
- **Hardened.** No shell, strict input validation, and time limits that stop and clean up long scans.
|
|
36
|
+
- **Tested.** End-to-end tests with fake `docker` and `dnstwist` executables run in CI on Node 20, 22 and 24.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
```mermaid
|
|
41
|
+
flowchart LR
|
|
42
|
+
A["AI agent<br/>(Claude, Codex, Gemini, VS Code)"] -->|"MCP over stdio"| S["mcp-dnstwist"]
|
|
43
|
+
S -->|"validated argv, no shell,<br/>time limit"| D["dnstwist<br/>(local or Docker)"]
|
|
44
|
+
D -->|"DNS, WHOIS, HTTP, SMTP"| I["Lookalike domains"]
|
|
45
|
+
D -->|"JSON"| S
|
|
46
|
+
S -->|"results"| A
|
|
32
47
|
```
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
1. Install Docker:
|
|
36
|
-
- macOS: Install [Docker Desktop](https://www.docker.com/products/docker-desktop)
|
|
37
|
-
- Linux: Follow the [Docker Engine installation guide](https://docs.docker.com/engine/install/)
|
|
49
|
+
## Quickstart
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
npm install -g @burtthecoder/mcp-dnstwist
|
|
42
|
-
```
|
|
51
|
+
**Requirements:** Node.js 20.3 or newer, and either dnstwist (`pipx install "dnstwist[full]"`) or Docker. No API key.
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"mcpServers": {
|
|
48
|
-
"dnstwist": {
|
|
49
|
-
"command": "mcp-dnstwist"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
```
|
|
53
|
+
```sh
|
|
54
|
+
# Claude Code
|
|
55
|
+
claude mcp add --transport stdio dnstwist -- npx -y @burtthecoder/mcp-dnstwist
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
57
|
+
# Codex CLI
|
|
58
|
+
codex mcp add dnstwist -- npx -y @burtthecoder/mcp-dnstwist
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
# Gemini CLI
|
|
61
|
+
gemini mcp add dnstwist npx -y @burtthecoder/mcp-dnstwist
|
|
62
|
+
```
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
For Claude Desktop, VS Code, Docker, Smithery and running from source, see [Connect a client](docs/clients.md).
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
Then ask your agent: *"Which lookalike domains of example.com are registered, and could any of them intercept email?"* More prompts are in [example workflows](docs/examples.md).
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
git clone <repository_url>
|
|
68
|
-
cd mcp-dnstwist
|
|
69
|
-
npm install
|
|
70
|
-
npm run build
|
|
71
|
-
```
|
|
68
|
+
## The tool
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
```json
|
|
75
|
-
{
|
|
76
|
-
"mcpServers": {
|
|
77
|
-
"dnstwist": {
|
|
78
|
-
"command": "node",
|
|
79
|
-
"args": ["/absolute/path/to/mcp-dnstwist/build/index.js"]
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
```
|
|
70
|
+
One tool, `fuzz_domain`. Full parameters and real sample output are in the [tool reference](docs/tools.md).
|
|
84
71
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
### Domain Fuzzing Tool
|
|
99
|
-
- Name: `fuzz_domain`
|
|
100
|
-
- Description: Generate and analyze domain permutations to detect potential typosquatting, phishing, and brand impersonation
|
|
101
|
-
- Parameters:
|
|
102
|
-
* `domain` (required): Domain name to analyze (e.g., example.com)
|
|
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
|
-
* `format` (optional, default: "json"): Output format (json, csv, list)
|
|
106
|
-
* `registered_only` (optional, default: true): Show only registered domains
|
|
107
|
-
* `mxcheck` (optional, default: true): Check for MX records
|
|
108
|
-
* `ssdeep` (optional, default: false): Generate fuzzy hashes of web pages
|
|
109
|
-
* `banners` (optional, default: true): Capture HTTP banner information
|
|
110
|
-
|
|
111
|
-
Example:
|
|
112
|
-
```json
|
|
113
|
-
{
|
|
114
|
-
"domain": "example.com",
|
|
115
|
-
"nameservers": "1.1.1.1,8.8.8.8",
|
|
116
|
-
"threads": 50,
|
|
117
|
-
"format": "json",
|
|
118
|
-
"registered_only": true,
|
|
119
|
-
"mxcheck": true,
|
|
120
|
-
"banners": true
|
|
121
|
-
}
|
|
122
|
-
```
|
|
72
|
+
| Parameter | Default | What it does |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `domain` | *(required)* | The domain to imitate, for example `example.com` |
|
|
75
|
+
| `registered_only` | `true` | Only return lookalikes that exist |
|
|
76
|
+
| `fuzzers` | all | Limit to some algorithms, for example `["homoglyph", "bitsquatting"]` |
|
|
77
|
+
| `mxcheck` | `true` | Flag mail servers that would accept email for any address |
|
|
78
|
+
| `banners` | `true` | Capture HTTP and SMTP banners |
|
|
79
|
+
| `whois` | `false` | Registration date and registrar |
|
|
80
|
+
| `lsh` | off | Web-page similarity to the original: `ssdeep` or `tlsh` |
|
|
81
|
+
| `all_records` | `false` | All DNS records, not only the first of each type |
|
|
82
|
+
| `nameservers` | `1.1.1.1` | Up to 10 IPv4 addresses or `https://<host>/dns-query` URLs |
|
|
83
|
+
| `threads` | `50` | Parallel lookups, 1 to 100 |
|
|
84
|
+
| `format` | `json` | `json`, `csv` or `list` |
|
|
123
85
|
|
|
124
|
-
|
|
86
|
+
### Settings
|
|
125
87
|
|
|
126
|
-
|
|
88
|
+
| Variable | Default | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `DNSTWIST_RUNNER` | `auto` | `local` (dnstwist on `PATH`), `docker` (the `elceef/dnstwist` image), or `auto` (local if available) |
|
|
91
|
+
| `DNSTWIST_TIMEOUT_SECONDS` | `600` | Longest a scan may run. On timeout, cancellation or shutdown the scan is stopped and cleaned up |
|
|
127
92
|
|
|
128
|
-
|
|
129
|
-
```bash
|
|
130
|
-
docker --version
|
|
131
|
-
docker ps
|
|
132
|
-
```
|
|
93
|
+
## Security
|
|
133
94
|
|
|
134
|
-
|
|
135
|
-
- Ensure your user has permissions to run Docker commands
|
|
136
|
-
- On Linux, add your user to the docker group: `sudo usermod -aG docker $USER`
|
|
95
|
+
The tool's arguments come from an AI model, which content it reads can steer, so the server treats them as hostile:
|
|
137
96
|
|
|
138
|
-
|
|
97
|
+
- **No shell.** dnstwist starts with `execFile` and an argument array; a test fails the build if a shell-based API appears.
|
|
98
|
+
- **Strict validation.** Domains must be hostnames, nameservers IPv4 or DoH URLs, numbers and names from fixed sets. No value can start with `-`.
|
|
99
|
+
- **Bounded runs.** Time limits, and cleanup of processes and containers on timeout, cancellation and shutdown.
|
|
139
100
|
|
|
140
|
-
|
|
141
|
-
- Verify DNS servers are accessible
|
|
142
|
-
- Try alternative DNS servers (e.g., 8.8.8.8)
|
|
143
|
-
- Check for rate limiting or blocking
|
|
101
|
+
The Docker runner needs access to the Docker daemon (root-equivalent on most systems). The local runner or the self-contained image avoid that. Details in the [security model](docs/security.md). Report vulnerabilities privately; see [SECURITY.md](SECURITY.md).
|
|
144
102
|
|
|
145
|
-
|
|
146
|
-
- Adjust thread count based on system capabilities
|
|
147
|
-
- Consider network bandwidth and latency
|
|
148
|
-
- Monitor DNS server response times
|
|
103
|
+
## Troubleshooting
|
|
149
104
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
105
|
+
- **"Neither dnstwist nor Docker is available":** install dnstwist (`pipx install "dnstwist[full]"`) or start Docker. Desktop clients may not see your shell's `PATH`.
|
|
106
|
+
- **Slow on Apple Silicon:** the dnstwist Docker image is amd64-only. Install dnstwist locally; it's used automatically.
|
|
107
|
+
- **"did not finish within N seconds":** include an IPv4 nameserver (DNS-over-HTTPS is slow), turn off `whois`, `lsh` or `banners`, or raise `DNSTWIST_TIMEOUT_SECONDS`.
|
|
153
108
|
|
|
154
|
-
|
|
109
|
+
More in the [troubleshooting guide](docs/troubleshooting.md).
|
|
155
110
|
|
|
156
|
-
|
|
157
|
-
- "Failed to parse dnstwist output": Check if the domain is valid and the format is correct
|
|
158
|
-
- "Error executing dnstwist": Check Docker logs and ensure proper permissions
|
|
159
|
-
- "DNS server not responding": Verify DNS server accessibility and try alternative servers
|
|
111
|
+
## Development
|
|
160
112
|
|
|
161
|
-
|
|
113
|
+
```sh
|
|
114
|
+
git clone https://github.com/w0h1v/mcp-dnstwist && cd mcp-dnstwist
|
|
115
|
+
npm ci
|
|
116
|
+
npm test # fake docker and dnstwist executables: no Docker or network needed
|
|
117
|
+
```
|
|
162
118
|
|
|
163
|
-
|
|
164
|
-
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
165
|
-
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
166
|
-
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
167
|
-
5. Open a Pull Request
|
|
119
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and the [changelog](CHANGELOG.md).
|
|
168
120
|
|
|
169
121
|
## License
|
|
170
122
|
|
|
171
|
-
|
|
123
|
+
MIT. See [LICENSE](LICENSE).
|
package/build/index.js
CHANGED
|
@@ -3,33 +3,93 @@ 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
|
-
import { validateFuzzArgs } from './validate.js';
|
|
8
|
-
// execFile runs docker directly with an argument array. No shell is involved,
|
|
8
|
+
import { FUZZERS, LSH_ALGORITHMS, validateFuzzArgs } from './validate.js';
|
|
9
|
+
// execFile runs docker (or dnstwist) 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), LSH and 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();
|
|
28
|
+
const RUNNER_SETTINGS = ['docker', 'local', 'auto'];
|
|
29
|
+
// docker: run the elceef/dnstwist image. local: run a dnstwist on PATH.
|
|
30
|
+
// auto (default): local when a dnstwist executable works, otherwise docker.
|
|
31
|
+
function runnerSetting() {
|
|
32
|
+
const raw = process.env.DNSTWIST_RUNNER;
|
|
33
|
+
if (raw === undefined || raw === '')
|
|
34
|
+
return 'auto';
|
|
35
|
+
if (!RUNNER_SETTINGS.includes(raw)) {
|
|
36
|
+
console.error(`DNSTWIST_RUNNER must be one of ${RUNNER_SETTINGS.join(', ')}, got ${JSON.stringify(raw)}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
const RUNNER_SETTING = runnerSetting();
|
|
12
42
|
class DnstwistServer {
|
|
13
43
|
constructor() {
|
|
44
|
+
// Scans in progress: id -> controller that stops the child process, plus the
|
|
45
|
+
// container name when the docker runner started one.
|
|
46
|
+
this.runningScans = new Map();
|
|
14
47
|
this.server = new Server({ name: 'dnstwist-server', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
15
48
|
this.setupToolHandlers();
|
|
16
49
|
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
50
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
51
|
+
process.on(signal, async () => {
|
|
52
|
+
// Don't leave scans running after the server is gone: stop each
|
|
53
|
+
// in-flight child process first, then remove any docker container.
|
|
54
|
+
const scans = [...this.runningScans.values()];
|
|
55
|
+
for (const scan of scans)
|
|
56
|
+
scan.stop.abort();
|
|
57
|
+
await Promise.all(scans.flatMap((scan) => (scan.container ? [this.removeContainer(scan.container)] : [])));
|
|
58
|
+
await this.server.close();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
21
62
|
// Trigger setup immediately
|
|
22
63
|
this.ensureSetup().catch(error => {
|
|
23
64
|
console.error('Failed to setup dnstwist:', error);
|
|
24
65
|
process.exit(1);
|
|
25
66
|
});
|
|
26
67
|
}
|
|
27
|
-
async execCommand(file, args) {
|
|
68
|
+
async execCommand(file, args, options = {}) {
|
|
28
69
|
console.error('Executing:', file, JSON.stringify(args));
|
|
29
70
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
71
|
+
const { killOnAbort, ...execOptions } = options;
|
|
72
|
+
const pending = execFileAsync(file, args, {
|
|
73
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
74
|
+
...execOptions,
|
|
75
|
+
// execFile's own `signal` support always sends SIGTERM; when the process
|
|
76
|
+
// must be killed outright, handle the abort here instead.
|
|
77
|
+
...(killOnAbort ? { signal: undefined } : {})
|
|
32
78
|
});
|
|
79
|
+
const abort = () => pending.child.kill('SIGKILL');
|
|
80
|
+
if (killOnAbort && options.signal) {
|
|
81
|
+
if (options.signal.aborted)
|
|
82
|
+
abort();
|
|
83
|
+
else
|
|
84
|
+
options.signal.addEventListener('abort', abort, { once: true });
|
|
85
|
+
}
|
|
86
|
+
let result;
|
|
87
|
+
try {
|
|
88
|
+
result = await pending;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
options.signal?.removeEventListener('abort', abort);
|
|
92
|
+
}
|
|
33
93
|
console.error('Command output:', result.stdout);
|
|
34
94
|
if (result.stderr)
|
|
35
95
|
console.error('Command stderr:', result.stderr);
|
|
@@ -40,26 +100,84 @@ class DnstwistServer {
|
|
|
40
100
|
throw error;
|
|
41
101
|
}
|
|
42
102
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
catch (error) {
|
|
50
|
-
throw new Error('Docker is not installed or not running. Please install Docker and try again.');
|
|
51
|
-
}
|
|
52
|
-
console.error('Checking if dnstwist image exists...');
|
|
103
|
+
// Killing the docker CLI doesn't stop the container it started, so a scan
|
|
104
|
+
// that times out or is cancelled is removed by name. Right after the CLI
|
|
105
|
+
// starts, the daemon may not have created the container yet ("No such
|
|
106
|
+
// container"), so keep trying for a few seconds until a removal succeeds.
|
|
107
|
+
async removeContainer(name, attempts = 10, delayMs = 500) {
|
|
108
|
+
for (let i = 0; i < attempts; i++) {
|
|
53
109
|
try {
|
|
54
|
-
await
|
|
55
|
-
|
|
110
|
+
await execFileAsync('docker', ['rm', '-f', name]);
|
|
111
|
+
return;
|
|
56
112
|
}
|
|
57
|
-
catch
|
|
58
|
-
|
|
59
|
-
await this.execCommand('docker', ['pull', DOCKER_IMAGE]);
|
|
60
|
-
console.error('Dnstwist image pulled successfully');
|
|
113
|
+
catch {
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
61
115
|
}
|
|
62
116
|
}
|
|
117
|
+
console.error(`Could not confirm removal of container ${name}`);
|
|
118
|
+
}
|
|
119
|
+
// Decided once. A failed check is not cached, so a later call can retry.
|
|
120
|
+
getRunner() {
|
|
121
|
+
this.runnerPromise ?? (this.runnerPromise = this.selectRunner().catch((error) => {
|
|
122
|
+
this.runnerPromise = undefined;
|
|
123
|
+
throw error;
|
|
124
|
+
}));
|
|
125
|
+
return this.runnerPromise;
|
|
126
|
+
}
|
|
127
|
+
async checkLocal() {
|
|
128
|
+
try {
|
|
129
|
+
await this.execCommand('dnstwist', ['--version']);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw new Error('The dnstwist executable was not found or does not work (`dnstwist --version` failed). ' +
|
|
133
|
+
'Install it (for example `pipx install "dnstwist[full]"`) or set DNSTWIST_RUNNER=docker.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async selectRunner() {
|
|
137
|
+
if (RUNNER_SETTING === 'docker')
|
|
138
|
+
return 'docker';
|
|
139
|
+
if (RUNNER_SETTING === 'local') {
|
|
140
|
+
await this.checkLocal();
|
|
141
|
+
console.error('Using the local dnstwist executable (DNSTWIST_RUNNER=local)');
|
|
142
|
+
return 'local';
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await this.checkLocal();
|
|
146
|
+
console.error('Using the local dnstwist executable (DNSTWIST_RUNNER=auto)');
|
|
147
|
+
return 'local';
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
console.error('No working dnstwist executable on PATH; using Docker (DNSTWIST_RUNNER=auto)');
|
|
151
|
+
return 'docker';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async checkDocker() {
|
|
155
|
+
console.error('Checking Docker...');
|
|
156
|
+
try {
|
|
157
|
+
await this.execCommand('docker', ['--version']);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
throw new Error('Docker is not installed or not running. Please install Docker and try again.');
|
|
161
|
+
}
|
|
162
|
+
console.error('Checking if dnstwist image exists...');
|
|
163
|
+
try {
|
|
164
|
+
await this.execCommand('docker', ['image', 'inspect', DOCKER_IMAGE]);
|
|
165
|
+
console.error('Dnstwist image found');
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
console.error('Dnstwist image not found, pulling...');
|
|
169
|
+
await this.execCommand('docker', ['pull', DOCKER_IMAGE]);
|
|
170
|
+
console.error('Dnstwist image pulled successfully');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async ensureSetup() {
|
|
174
|
+
try {
|
|
175
|
+
const runner = await this.getRunner();
|
|
176
|
+
// Docker is re-checked per call: the image or daemon can disappear.
|
|
177
|
+
if (runner === 'docker')
|
|
178
|
+
await this.checkDocker();
|
|
179
|
+
return runner;
|
|
180
|
+
}
|
|
63
181
|
catch (error) {
|
|
64
182
|
console.error('Setup failed:', error);
|
|
65
183
|
throw error;
|
|
@@ -85,7 +203,7 @@ class DnstwistServer {
|
|
|
85
203
|
},
|
|
86
204
|
threads: {
|
|
87
205
|
type: 'number',
|
|
88
|
-
description: 'Number of
|
|
206
|
+
description: 'Number of parallel lookups (1-100)',
|
|
89
207
|
minimum: 1,
|
|
90
208
|
maximum: 100,
|
|
91
209
|
default: 50
|
|
@@ -103,17 +221,40 @@ class DnstwistServer {
|
|
|
103
221
|
},
|
|
104
222
|
mxcheck: {
|
|
105
223
|
type: 'boolean',
|
|
106
|
-
description: 'Check for
|
|
224
|
+
description: 'Check whether each mail server would accept email for any address, so it could intercept mail sent to typos of your domain',
|
|
107
225
|
default: true
|
|
108
226
|
},
|
|
109
227
|
ssdeep: {
|
|
110
228
|
type: 'boolean',
|
|
111
|
-
description: '
|
|
229
|
+
description: 'Deprecated alias for lsh: "ssdeep" (ignored when lsh is set)',
|
|
112
230
|
default: false
|
|
113
231
|
},
|
|
232
|
+
lsh: {
|
|
233
|
+
type: 'string',
|
|
234
|
+
enum: [...LSH_ALGORITHMS],
|
|
235
|
+
description: 'Compare each permutation\'s web page with the original using a fuzzy hash (slow) to spot cloned phishing pages'
|
|
236
|
+
},
|
|
237
|
+
whois: {
|
|
238
|
+
type: 'boolean',
|
|
239
|
+
description: 'Look up WHOIS creation date and registrar for each domain (slower)',
|
|
240
|
+
default: false
|
|
241
|
+
},
|
|
242
|
+
all_records: {
|
|
243
|
+
type: 'boolean',
|
|
244
|
+
description: 'Return all DNS records instead of only the first of each type',
|
|
245
|
+
default: false
|
|
246
|
+
},
|
|
247
|
+
fuzzers: {
|
|
248
|
+
type: 'array',
|
|
249
|
+
items: { type: 'string', enum: [...FUZZERS] },
|
|
250
|
+
minItems: 1,
|
|
251
|
+
maxItems: FUZZERS.length,
|
|
252
|
+
uniqueItems: true,
|
|
253
|
+
description: 'Only use these fuzzing algorithms (default: all)'
|
|
254
|
+
},
|
|
114
255
|
banners: {
|
|
115
256
|
type: 'boolean',
|
|
116
|
-
description: 'Capture HTTP
|
|
257
|
+
description: 'Capture HTTP and SMTP server banners',
|
|
117
258
|
default: true
|
|
118
259
|
}
|
|
119
260
|
},
|
|
@@ -122,13 +263,13 @@ class DnstwistServer {
|
|
|
122
263
|
}
|
|
123
264
|
]
|
|
124
265
|
}));
|
|
125
|
-
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
266
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
126
267
|
try {
|
|
127
|
-
await this.ensureSetup();
|
|
268
|
+
const runner = await this.ensureSetup();
|
|
128
269
|
if (request.params.name !== 'fuzz_domain') {
|
|
129
270
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
130
271
|
}
|
|
131
|
-
const { domain, nameservers, threads, format, registered_only, mxcheck,
|
|
272
|
+
const { domain, nameservers, threads, format, registered_only, mxcheck, banners, whois, all_records, fuzzers, lsh } = validateFuzzArgs(request.params.arguments);
|
|
132
273
|
// Build command arguments
|
|
133
274
|
const args = [
|
|
134
275
|
domain,
|
|
@@ -142,12 +283,45 @@ class DnstwistServer {
|
|
|
142
283
|
args.push('-t', threads.toString());
|
|
143
284
|
if (mxcheck)
|
|
144
285
|
args.push('--mxcheck');
|
|
145
|
-
if (
|
|
146
|
-
args.push('--
|
|
286
|
+
if (lsh)
|
|
287
|
+
args.push('--lsh', lsh);
|
|
147
288
|
if (banners)
|
|
148
289
|
args.push('-b');
|
|
149
|
-
|
|
150
|
-
|
|
290
|
+
if (whois)
|
|
291
|
+
args.push('-w');
|
|
292
|
+
if (all_records)
|
|
293
|
+
args.push('-a');
|
|
294
|
+
if (fuzzers)
|
|
295
|
+
args.push('--fuzzers', fuzzers.join(','));
|
|
296
|
+
const id = randomUUID();
|
|
297
|
+
const container = runner === 'docker' ? `mcp-dnstwist-${id}` : undefined;
|
|
298
|
+
const stop = new AbortController();
|
|
299
|
+
this.runningScans.set(id, { stop, container });
|
|
300
|
+
let stdout;
|
|
301
|
+
try {
|
|
302
|
+
const options = { timeout: TIMEOUT_SECONDS * 1000, signal: AbortSignal.any([extra.signal, stop.signal]) };
|
|
303
|
+
({ stdout } = container
|
|
304
|
+
? await this.execCommand('docker', ['run', '--rm', '--name', container, DOCKER_IMAGE, ...args], options)
|
|
305
|
+
// dnstwist handles SIGTERM itself and may wind down slowly: kill it outright.
|
|
306
|
+
: await this.execCommand('dnstwist', args, { ...options, killSignal: 'SIGKILL', killOnAbort: true }));
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const stopped = extra.signal.aborted || stop.signal.aborted;
|
|
310
|
+
const timedOut = Boolean(error.killed) && !stopped;
|
|
311
|
+
// If dnstwist exited on its own, --rm already removed the container.
|
|
312
|
+
if (container && (timedOut || stopped))
|
|
313
|
+
await this.removeContainer(container);
|
|
314
|
+
if (timedOut) {
|
|
315
|
+
const unit = TIMEOUT_SECONDS === 1 ? 'second' : 'seconds';
|
|
316
|
+
throw new Error(`dnstwist did not finish within ${TIMEOUT_SECONDS} ${unit} and was stopped. ` +
|
|
317
|
+
'Use an IPv4 nameserver (DNS-over-HTTPS is slow), turn off lsh, whois or banners, ' +
|
|
318
|
+
'or raise DNSTWIST_TIMEOUT_SECONDS.');
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
this.runningScans.delete(id);
|
|
324
|
+
}
|
|
151
325
|
// csv and list output are returned as dnstwist printed them.
|
|
152
326
|
if (format !== 'json') {
|
|
153
327
|
return { content: [{ type: 'text', text: stdout }] };
|
package/build/validate.js
CHANGED
|
@@ -6,6 +6,13 @@ import { domainToASCII } from 'url';
|
|
|
6
6
|
// mistaken for a dnstwist option either.
|
|
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
|
+
// dnstwist's fuzzing algorithms, minus dictionary and tld-swap: those need a
|
|
10
|
+
// word or TLD list file, which this tool doesn't take.
|
|
11
|
+
export const FUZZERS = [
|
|
12
|
+
'addition', 'bitsquatting', 'cyrillic', 'homoglyph', 'hyphenation', 'insertion', 'omission',
|
|
13
|
+
'plural', 'repetition', 'replacement', 'subdomain', 'transposition', 'vowel-swap', 'various',
|
|
14
|
+
];
|
|
15
|
+
export const LSH_ALGORITHMS = ['ssdeep', 'tlsh'];
|
|
9
16
|
const MAX_NAMESERVERS = 10;
|
|
10
17
|
// The DNS-over-HTTPS form dnstwist accepts (dnstwist.py --nameservers parsing).
|
|
11
18
|
const DOH = /^https:\/\/([a-z0-9.-]{4,253})\/dns-query$/;
|
|
@@ -65,6 +72,22 @@ export function validateFuzzArgs(args) {
|
|
|
65
72
|
if (!FORMATS.includes(format)) {
|
|
66
73
|
invalid(`format must be one of ${FORMATS.join(', ')}`);
|
|
67
74
|
}
|
|
75
|
+
let fuzzers;
|
|
76
|
+
if (a.fuzzers !== undefined) {
|
|
77
|
+
const list = a.fuzzers;
|
|
78
|
+
if (!Array.isArray(list) || list.length < 1 || list.length > FUZZERS.length ||
|
|
79
|
+
list.some((f) => typeof f !== 'string' || !FUZZERS.includes(f)) ||
|
|
80
|
+
new Set(list).size !== list.length) {
|
|
81
|
+
invalid(`fuzzers must be 1 to ${FUZZERS.length} unique names from: ${FUZZERS.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
fuzzers = list;
|
|
84
|
+
}
|
|
85
|
+
if (a.lsh !== undefined && !LSH_ALGORITHMS.includes(a.lsh)) {
|
|
86
|
+
invalid(`lsh must be one of ${LSH_ALGORITHMS.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
const ssdeep = optionalBoolean(a, 'ssdeep', false);
|
|
89
|
+
// `ssdeep: true` is the older spelling of `lsh: 'ssdeep'`; an explicit lsh wins.
|
|
90
|
+
const lsh = a.lsh ?? (ssdeep ? 'ssdeep' : undefined);
|
|
68
91
|
return {
|
|
69
92
|
domain: domain,
|
|
70
93
|
nameservers: servers.join(','),
|
|
@@ -72,7 +95,11 @@ export function validateFuzzArgs(args) {
|
|
|
72
95
|
format: format,
|
|
73
96
|
registered_only: optionalBoolean(a, 'registered_only', true),
|
|
74
97
|
mxcheck: optionalBoolean(a, 'mxcheck', true),
|
|
75
|
-
ssdeep
|
|
98
|
+
ssdeep,
|
|
76
99
|
banners: optionalBoolean(a, 'banners', true),
|
|
100
|
+
whois: optionalBoolean(a, 'whois', false),
|
|
101
|
+
all_records: optionalBoolean(a, 'all_records', false),
|
|
102
|
+
...(fuzzers ? { fuzzers } : {}),
|
|
103
|
+
...(lsh ? { lsh } : {}),
|
|
77
104
|
};
|
|
78
105
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burtthecoder/mcp-dnstwist",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "MCP server for dnstwist
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for dnstwist: find typosquatting, homoglyph and lookalike domains used for phishing and brand impersonation",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && chmod +x build/index.js",
|
|
17
|
+
"start": "node build/index.js",
|
|
18
|
+
"dev": "tsc -w",
|
|
17
19
|
"prepublishOnly": "npm run build",
|
|
18
20
|
"test": "npm run build && node --test tests/*.test.mjs"
|
|
19
21
|
},
|
|
@@ -37,11 +39,22 @@
|
|
|
37
39
|
"typosquatting",
|
|
38
40
|
"domain-security",
|
|
39
41
|
"claude",
|
|
40
|
-
"claude-desktop"
|
|
42
|
+
"claude-desktop",
|
|
43
|
+
"mcp-server",
|
|
44
|
+
"model-context-protocol",
|
|
45
|
+
"brand-protection",
|
|
46
|
+
"threat-intelligence",
|
|
47
|
+
"dfir",
|
|
48
|
+
"phishing-detection"
|
|
41
49
|
],
|
|
42
|
-
"author": "
|
|
50
|
+
"author": "w0h1v",
|
|
43
51
|
"license": "MIT",
|
|
44
52
|
"engines": {
|
|
45
|
-
"node": ">=20"
|
|
46
|
-
}
|
|
53
|
+
"node": ">=20.3"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://w0h1v.github.io/mcp-dnstwist/",
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/w0h1v/mcp-dnstwist/issues"
|
|
58
|
+
},
|
|
59
|
+
"mcpName": "io.github.w0h1v/dnstwist"
|
|
47
60
|
}
|