mpx-scan 1.0.2 → 1.2.1
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 +152 -94
- package/bin/cli.js +241 -58
- package/package.json +7 -2
- package/src/index.js +1 -1
- package/src/mcp.js +260 -0
- package/src/scanners/cookies.js +13 -1
- package/src/scanners/exposed-files.js +1 -1
- package/src/scanners/fingerprint.js +9 -2
- package/src/scanners/headers.js +72 -46
- package/src/scanners/redirects.js +1 -1
- package/src/scanners/server.js +16 -5
- package/src/scanners/sri.js +1 -1
- package/src/schema.js +198 -0
package/bin/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ const { scan } = require('../src/index');
|
|
|
13
13
|
const { formatReport, formatBrief } = require('../src/reporters/terminal');
|
|
14
14
|
const { formatJSON } = require('../src/reporters/json');
|
|
15
15
|
const { generateFixes, PLATFORMS } = require('../src/generators/fixes');
|
|
16
|
+
const { getSchema } = require('../src/schema');
|
|
16
17
|
const {
|
|
17
18
|
getLicense,
|
|
18
19
|
activateLicense,
|
|
@@ -24,6 +25,18 @@ const {
|
|
|
24
25
|
|
|
25
26
|
const pkg = require('../package.json');
|
|
26
27
|
|
|
28
|
+
// Exit codes per AI-native spec
|
|
29
|
+
const EXIT = {
|
|
30
|
+
SUCCESS: 0, // Success, no issues found
|
|
31
|
+
ISSUES_FOUND: 1, // Success, issues found
|
|
32
|
+
BAD_ARGS: 2, // Invalid arguments
|
|
33
|
+
CONFIG_ERROR: 3, // Configuration error
|
|
34
|
+
NETWORK_ERROR: 4 // Network/connectivity error
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Auto-detect non-interactive mode
|
|
38
|
+
const isInteractive = process.stdout.isTTY && !process.env.CI;
|
|
39
|
+
|
|
27
40
|
const program = new Command();
|
|
28
41
|
|
|
29
42
|
program
|
|
@@ -32,105 +45,252 @@ program
|
|
|
32
45
|
.version(pkg.version)
|
|
33
46
|
.argument('[url]', 'URL to scan')
|
|
34
47
|
.option('--full', 'Run all checks (Pro only)')
|
|
35
|
-
.option('--json', 'Output as JSON')
|
|
48
|
+
.option('--json', 'Output as JSON (machine-readable)')
|
|
36
49
|
.option('--brief', 'Brief output (one-line summary)')
|
|
50
|
+
.option('--quiet, -q', 'Minimal output (results only, no banners)')
|
|
51
|
+
.option('--no-color', 'Disable colored output')
|
|
52
|
+
.option('--batch', 'Batch mode: read URLs from stdin (one per line)')
|
|
53
|
+
.option('--schema', 'Output JSON schema describing all commands and flags')
|
|
37
54
|
.option('--fix <platform>', `Generate fix config for platform (${PLATFORMS.join(', ')})`)
|
|
38
55
|
.option('--timeout <seconds>', 'Connection timeout', '10')
|
|
39
56
|
.option('--ci', 'CI/CD mode: exit 1 if score below threshold')
|
|
40
57
|
.option('--min-score <score>', 'Minimum score for CI mode (default: 70)', '70')
|
|
41
58
|
.action(async (url, options) => {
|
|
59
|
+
// Handle --schema flag
|
|
60
|
+
if (options.schema) {
|
|
61
|
+
console.log(JSON.stringify(getSchema(), null, 2));
|
|
62
|
+
process.exit(EXIT.SUCCESS);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle --batch mode (read URLs from stdin)
|
|
67
|
+
if (options.batch) {
|
|
68
|
+
await runBatchMode(options);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
// Show help if no URL provided
|
|
43
73
|
if (!url) {
|
|
44
74
|
program.help();
|
|
45
75
|
return;
|
|
46
76
|
}
|
|
47
77
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
const exitCode = await runSingleScan(url, options);
|
|
79
|
+
process.exit(exitCode);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function runSingleScan(url, options) {
|
|
83
|
+
const jsonMode = options.json;
|
|
84
|
+
const quietMode = options.quiet || options.Q;
|
|
85
|
+
|
|
86
|
+
// Disable chalk if --no-color or non-TTY
|
|
87
|
+
if (options.color === false || !process.stdout.isTTY) {
|
|
88
|
+
chalk.level = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Check license and rate limits
|
|
93
|
+
const license = getLicense();
|
|
94
|
+
const rateLimit = checkRateLimit();
|
|
95
|
+
|
|
96
|
+
// Handle rate limiting
|
|
97
|
+
if (!rateLimit.allowed) {
|
|
98
|
+
if (jsonMode) {
|
|
99
|
+
console.log(JSON.stringify({
|
|
100
|
+
error: 'Daily scan limit reached',
|
|
101
|
+
code: 'ERR_RATE_LIMIT',
|
|
102
|
+
resetsAt: new Date(rateLimit.resetsAt).toISOString(),
|
|
103
|
+
limit: FREE_DAILY_LIMIT,
|
|
104
|
+
upgrade: 'https://mesaplex.com/mpx-scan'
|
|
105
|
+
}, null, 2));
|
|
106
|
+
} else {
|
|
55
107
|
console.error(chalk.red.bold('\n❌ Daily scan limit reached'));
|
|
56
108
|
console.error(chalk.yellow(`Free tier: ${FREE_DAILY_LIMIT} scans/day`));
|
|
57
109
|
console.error(chalk.gray(`Resets: ${new Date(rateLimit.resetsAt).toLocaleString()}\n`));
|
|
58
110
|
console.error(chalk.blue('Upgrade to Pro for unlimited scans:'));
|
|
59
111
|
console.error(chalk.blue(' https://mesaplex.com/mpx-scan\n'));
|
|
60
|
-
process.exit(1);
|
|
61
112
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
113
|
+
return EXIT.CONFIG_ERROR;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check for Pro-only features
|
|
117
|
+
if (options.full && license.tier !== 'pro') {
|
|
118
|
+
if (jsonMode) {
|
|
119
|
+
console.log(JSON.stringify({
|
|
120
|
+
error: '--full flag requires Pro license',
|
|
121
|
+
code: 'ERR_PRO_REQUIRED',
|
|
122
|
+
upgrade: 'https://mesaplex.com/mpx-scan'
|
|
123
|
+
}, null, 2));
|
|
124
|
+
} else {
|
|
65
125
|
console.error(chalk.red.bold('\n❌ --full flag requires Pro license'));
|
|
66
126
|
console.error(chalk.yellow('Free tier includes: headers, SSL, server checks'));
|
|
67
127
|
console.error(chalk.yellow('Pro includes: all checks (DNS, cookies, SRI, exposed files, etc.)\n'));
|
|
68
128
|
console.error(chalk.blue('Upgrade: https://mesaplex.com/mpx-scan\n'));
|
|
69
|
-
process.exit(1);
|
|
70
129
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
130
|
+
return EXIT.CONFIG_ERROR;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Show scan info (unless quiet/json/brief)
|
|
134
|
+
if (!jsonMode && !options.brief && !quietMode) {
|
|
135
|
+
console.error(chalk.bold.cyan('🔍 Scanning...'));
|
|
136
|
+
if (license.tier === 'free') {
|
|
137
|
+
console.error(chalk.gray(`Free tier: ${rateLimit.remaining} scan(s) remaining today`));
|
|
76
138
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Run scan
|
|
142
|
+
const results = await scan(url, {
|
|
143
|
+
timeout: parseInt(options.timeout) * 1000,
|
|
144
|
+
tier: license.tier,
|
|
145
|
+
full: options.full
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Record scan for rate limiting
|
|
149
|
+
recordScan();
|
|
150
|
+
|
|
151
|
+
// Output results
|
|
152
|
+
if (options.fix) {
|
|
153
|
+
console.log(generateFixes(options.fix, results));
|
|
154
|
+
} else if (jsonMode) {
|
|
155
|
+
console.log(formatJSON(results, true));
|
|
156
|
+
} else if (options.brief) {
|
|
157
|
+
console.log(formatBrief(results));
|
|
158
|
+
} else {
|
|
159
|
+
console.log(formatReport(results, { ...options, quiet: quietMode }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Determine exit code based on findings
|
|
163
|
+
if (options.ci) {
|
|
164
|
+
const minScore = parseInt(options.minScore);
|
|
165
|
+
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
166
|
+
if (percentage < minScore) {
|
|
167
|
+
if (!jsonMode && !options.brief && !quietMode) {
|
|
168
|
+
console.error(chalk.yellow(`\n⚠️ CI mode: Score ${percentage}/100 below minimum ${minScore}`));
|
|
84
169
|
}
|
|
170
|
+
return EXIT.ISSUES_FOUND;
|
|
85
171
|
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Exit 1 if there are failures, 0 if clean
|
|
175
|
+
if (results.summary.failed > 0) {
|
|
176
|
+
return EXIT.ISSUES_FOUND;
|
|
177
|
+
}
|
|
178
|
+
return EXIT.SUCCESS;
|
|
179
|
+
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (jsonMode) {
|
|
182
|
+
const code = isNetworkError(err) ? 'ERR_NETWORK' : 'ERR_SCAN';
|
|
183
|
+
console.log(JSON.stringify({ error: err.message, code }, null, 2));
|
|
184
|
+
} else {
|
|
185
|
+
console.error(chalk.red.bold('\n❌ Error:'), err.message);
|
|
186
|
+
console.error('');
|
|
187
|
+
}
|
|
188
|
+
return isNetworkError(err) ? EXIT.NETWORK_ERROR : EXIT.ISSUES_FOUND;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function runBatchMode(options) {
|
|
193
|
+
const jsonMode = options.json;
|
|
194
|
+
|
|
195
|
+
// Read URLs from stdin
|
|
196
|
+
const input = await readStdin();
|
|
197
|
+
if (!input.trim()) {
|
|
198
|
+
if (jsonMode) {
|
|
199
|
+
console.log(JSON.stringify({ error: 'No URLs provided on stdin', code: 'ERR_NO_INPUT' }, null, 2));
|
|
200
|
+
} else {
|
|
201
|
+
console.error(chalk.red('No URLs provided. Pipe URLs via stdin:'));
|
|
202
|
+
console.error(chalk.gray(' cat urls.txt | mpx-scan --batch --json'));
|
|
203
|
+
}
|
|
204
|
+
process.exit(EXIT.BAD_ARGS);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const urls = input.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
|
209
|
+
|
|
210
|
+
if (urls.length === 0) {
|
|
211
|
+
if (jsonMode) {
|
|
212
|
+
console.log(JSON.stringify({ error: 'No valid URLs found in input', code: 'ERR_NO_INPUT' }, null, 2));
|
|
213
|
+
} else {
|
|
214
|
+
console.error(chalk.red('No valid URLs found in input.'));
|
|
215
|
+
}
|
|
216
|
+
process.exit(EXIT.BAD_ARGS);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let hasIssues = false;
|
|
221
|
+
let hasErrors = false;
|
|
222
|
+
|
|
223
|
+
for (const url of urls) {
|
|
224
|
+
try {
|
|
225
|
+
const license = getLicense();
|
|
226
|
+
const rateLimit = checkRateLimit();
|
|
86
227
|
|
|
87
|
-
|
|
228
|
+
if (!rateLimit.allowed) {
|
|
229
|
+
if (jsonMode) {
|
|
230
|
+
console.log(JSON.stringify({
|
|
231
|
+
url,
|
|
232
|
+
error: 'Rate limit reached',
|
|
233
|
+
code: 'ERR_RATE_LIMIT'
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
hasErrors = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
88
240
|
const results = await scan(url, {
|
|
89
241
|
timeout: parseInt(options.timeout) * 1000,
|
|
90
242
|
tier: license.tier,
|
|
91
243
|
full: options.full
|
|
92
244
|
});
|
|
93
245
|
|
|
94
|
-
// Record scan for rate limiting
|
|
95
246
|
recordScan();
|
|
96
247
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
console.log(formatJSON(results,
|
|
248
|
+
if (results.summary.failed > 0) hasIssues = true;
|
|
249
|
+
|
|
250
|
+
if (jsonMode) {
|
|
251
|
+
// JSONL: one JSON object per line
|
|
252
|
+
console.log(formatJSON(results, false));
|
|
102
253
|
} else if (options.brief) {
|
|
103
254
|
console.log(formatBrief(results));
|
|
104
255
|
} else {
|
|
105
256
|
console.log(formatReport(results, options));
|
|
106
257
|
}
|
|
107
|
-
|
|
108
|
-
// Exit code logic:
|
|
109
|
-
// - Exit 0: scan completed successfully (default)
|
|
110
|
-
// - Exit 1: only in --ci mode if score below threshold
|
|
111
|
-
if (options.ci) {
|
|
112
|
-
const minScore = parseInt(options.minScore);
|
|
113
|
-
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
114
|
-
if (percentage < minScore) {
|
|
115
|
-
if (!options.json && !options.brief) {
|
|
116
|
-
console.error(chalk.yellow(`\n⚠️ CI mode: Score ${percentage}/100 below minimum ${minScore}`));
|
|
117
|
-
}
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
process.exit(0);
|
|
123
|
-
|
|
124
258
|
} catch (err) {
|
|
125
|
-
|
|
126
|
-
|
|
259
|
+
hasErrors = true;
|
|
260
|
+
if (jsonMode) {
|
|
261
|
+
console.log(JSON.stringify({ url, error: err.message, code: isNetworkError(err) ? 'ERR_NETWORK' : 'ERR_SCAN' }));
|
|
127
262
|
} else {
|
|
128
|
-
console.error(chalk.red
|
|
129
|
-
console.error('');
|
|
263
|
+
console.error(chalk.red(`Error scanning ${url}: ${err.message}`));
|
|
130
264
|
}
|
|
131
|
-
process.exit(1);
|
|
132
265
|
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (hasErrors) process.exit(EXIT.NETWORK_ERROR);
|
|
269
|
+
if (hasIssues) process.exit(EXIT.ISSUES_FOUND);
|
|
270
|
+
process.exit(EXIT.SUCCESS);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readStdin() {
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
if (process.stdin.isTTY) {
|
|
276
|
+
resolve('');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
let data = '';
|
|
280
|
+
process.stdin.setEncoding('utf8');
|
|
281
|
+
process.stdin.on('data', chunk => data += chunk);
|
|
282
|
+
process.stdin.on('end', () => resolve(data));
|
|
133
283
|
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isNetworkError(err) {
|
|
287
|
+
const msg = (err.message || '').toLowerCase();
|
|
288
|
+
return msg.includes('econnrefused') || msg.includes('enotfound') ||
|
|
289
|
+
msg.includes('timeout') || msg.includes('network') ||
|
|
290
|
+
msg.includes('dns') || msg.includes('econnreset') ||
|
|
291
|
+
err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' ||
|
|
292
|
+
err.code === 'ETIMEDOUT';
|
|
293
|
+
}
|
|
134
294
|
|
|
135
295
|
// License management subcommands
|
|
136
296
|
program
|
|
@@ -187,7 +347,7 @@ program
|
|
|
187
347
|
} catch (err) {
|
|
188
348
|
console.error(chalk.red.bold('\n❌ Activation failed:'), err.message);
|
|
189
349
|
console.error('');
|
|
190
|
-
process.exit(
|
|
350
|
+
process.exit(EXIT.CONFIG_ERROR);
|
|
191
351
|
}
|
|
192
352
|
});
|
|
193
353
|
|
|
@@ -202,20 +362,43 @@ program
|
|
|
202
362
|
console.log('');
|
|
203
363
|
});
|
|
204
364
|
|
|
365
|
+
// MCP subcommand
|
|
366
|
+
program
|
|
367
|
+
.command('mcp')
|
|
368
|
+
.description('Start MCP (Model Context Protocol) stdio server')
|
|
369
|
+
.action(async () => {
|
|
370
|
+
try {
|
|
371
|
+
const { startMCPServer } = require('../src/mcp');
|
|
372
|
+
await startMCPServer();
|
|
373
|
+
} catch (err) {
|
|
374
|
+
console.error(JSON.stringify({ error: err.message, code: 'ERR_MCP_START' }));
|
|
375
|
+
process.exit(EXIT.CONFIG_ERROR);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
205
379
|
// Examples
|
|
206
380
|
program.addHelpText('after', `
|
|
207
381
|
${chalk.bold('Examples:')}
|
|
208
382
|
${chalk.cyan('mpx-scan https://example.com')} Quick security scan
|
|
209
383
|
${chalk.cyan('mpx-scan example.com --full')} Deep scan (Pro only)
|
|
210
|
-
${chalk.cyan('mpx-scan example.com --json')} JSON output
|
|
384
|
+
${chalk.cyan('mpx-scan example.com --json')} JSON output
|
|
211
385
|
${chalk.cyan('mpx-scan example.com --fix nginx')} Generate nginx config
|
|
212
386
|
${chalk.cyan('mpx-scan example.com --brief')} One-line summary
|
|
387
|
+
${chalk.cyan('mpx-scan --schema')} Show tool schema (JSON)
|
|
388
|
+
${chalk.cyan('cat urls.txt | mpx-scan --batch --json')} Batch scan from stdin
|
|
389
|
+
${chalk.cyan('mpx-scan mcp')} Start MCP server
|
|
213
390
|
${chalk.cyan('mpx-scan license')} Check license status
|
|
214
|
-
|
|
391
|
+
|
|
392
|
+
${chalk.bold('Exit Codes:')}
|
|
393
|
+
0 Success, no issues found
|
|
394
|
+
1 Success, issues found
|
|
395
|
+
2 Invalid arguments
|
|
396
|
+
3 Configuration error (license, rate limit)
|
|
397
|
+
4 Network/connectivity error
|
|
215
398
|
|
|
216
399
|
${chalk.bold('Free vs Pro:')}
|
|
217
400
|
${chalk.yellow('Free:')} 3 scans/day, basic checks (headers, SSL, server)
|
|
218
|
-
${chalk.green('Pro:')} Unlimited scans, all checks,
|
|
401
|
+
${chalk.green('Pro:')} Unlimited scans, all checks, batch mode, CI/CD integration
|
|
219
402
|
|
|
220
403
|
${chalk.blue('Upgrade: https://mesaplex.com/mpx-scan')}
|
|
221
404
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mpx-scan",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Professional website security scanner CLI. Check headers, SSL, cookies, DNS, and get actionable fix suggestions. Part of the Mesaplex developer toolchain.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
"security-headers",
|
|
26
26
|
"ssl-check",
|
|
27
27
|
"dns-security",
|
|
28
|
-
"cors"
|
|
28
|
+
"cors",
|
|
29
|
+
"mcp",
|
|
30
|
+
"ai-native",
|
|
31
|
+
"model-context-protocol",
|
|
32
|
+
"automation"
|
|
29
33
|
],
|
|
30
34
|
"author": "Mesaplex <support@mesaplex.com>",
|
|
31
35
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -39,6 +43,7 @@
|
|
|
39
43
|
"node": ">=18.0.0"
|
|
40
44
|
},
|
|
41
45
|
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
42
47
|
"chalk": "^4.1.2",
|
|
43
48
|
"commander": "^12.0.0"
|
|
44
49
|
},
|
package/src/index.js
CHANGED
|
@@ -58,7 +58,7 @@ async function scan(url, options = {}) {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
const allScanners = [
|
|
61
|
-
{ name: 'headers', fn: scanHeaders, weight:
|
|
61
|
+
{ name: 'headers', fn: scanHeaders, weight: 15 },
|
|
62
62
|
{ name: 'ssl', fn: scanSSL, weight: 20 },
|
|
63
63
|
{ name: 'cookies', fn: scanCookies, weight: 10 },
|
|
64
64
|
{ name: 'server', fn: scanServer, weight: 8 },
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) Server
|
|
3
|
+
*
|
|
4
|
+
* Exposes mpx-scan capabilities as MCP tools for AI agent integration.
|
|
5
|
+
* Runs over stdio transport.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
9
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
10
|
+
const {
|
|
11
|
+
ListToolsRequestSchema,
|
|
12
|
+
CallToolRequestSchema
|
|
13
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
14
|
+
|
|
15
|
+
const { scan } = require('./index');
|
|
16
|
+
const { formatJSON } = require('./reporters/json');
|
|
17
|
+
const { getSchema } = require('./schema');
|
|
18
|
+
const { getLicense, checkRateLimit, recordScan } = require('./license');
|
|
19
|
+
const { generateFixes, PLATFORMS } = require('./generators/fixes');
|
|
20
|
+
const pkg = require('../package.json');
|
|
21
|
+
|
|
22
|
+
async function startMCPServer() {
|
|
23
|
+
const server = new Server(
|
|
24
|
+
{ name: 'mpx-scan', version: pkg.version },
|
|
25
|
+
{ capabilities: { tools: {} } }
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// List available tools
|
|
29
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
30
|
+
return {
|
|
31
|
+
tools: [
|
|
32
|
+
{
|
|
33
|
+
name: 'scan',
|
|
34
|
+
description: 'Scan a website for security issues. Returns structured results with grade, score, and per-check details.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
url: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'URL to scan (https:// added automatically if missing)'
|
|
41
|
+
},
|
|
42
|
+
full: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description: 'Run all security checks (Pro license required)',
|
|
45
|
+
default: false
|
|
46
|
+
},
|
|
47
|
+
timeout: {
|
|
48
|
+
type: 'number',
|
|
49
|
+
description: 'Connection timeout in seconds',
|
|
50
|
+
default: 10
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
required: ['url']
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'generate_fixes',
|
|
58
|
+
description: 'Generate platform-specific configuration to fix security issues found by a scan.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
url: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'URL to scan and generate fixes for'
|
|
65
|
+
},
|
|
66
|
+
platform: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
enum: PLATFORMS,
|
|
69
|
+
description: 'Target platform for fix configuration'
|
|
70
|
+
},
|
|
71
|
+
timeout: {
|
|
72
|
+
type: 'number',
|
|
73
|
+
description: 'Connection timeout in seconds',
|
|
74
|
+
default: 10
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
required: ['url', 'platform']
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'get_schema',
|
|
82
|
+
description: 'Get the full JSON schema describing all mpx-scan commands, flags, and output formats.',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Handle tool calls
|
|
93
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
94
|
+
const { name, arguments: args } = request.params;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
switch (name) {
|
|
98
|
+
case 'scan': {
|
|
99
|
+
const license = getLicense();
|
|
100
|
+
const rateLimit = checkRateLimit();
|
|
101
|
+
|
|
102
|
+
if (!rateLimit.allowed) {
|
|
103
|
+
return {
|
|
104
|
+
content: [{
|
|
105
|
+
type: 'text',
|
|
106
|
+
text: JSON.stringify({
|
|
107
|
+
error: 'Daily scan limit reached',
|
|
108
|
+
code: 'ERR_RATE_LIMIT',
|
|
109
|
+
resetsAt: new Date(rateLimit.resetsAt).toISOString()
|
|
110
|
+
}, null, 2)
|
|
111
|
+
}],
|
|
112
|
+
isError: true
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const results = await scan(args.url, {
|
|
117
|
+
timeout: (args.timeout || 10) * 1000,
|
|
118
|
+
tier: license.tier,
|
|
119
|
+
full: args.full || false
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
recordScan();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
content: [{
|
|
126
|
+
type: 'text',
|
|
127
|
+
text: formatJSON(results, true)
|
|
128
|
+
}]
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'generate_fixes': {
|
|
133
|
+
const license = getLicense();
|
|
134
|
+
const rateLimit2 = checkRateLimit();
|
|
135
|
+
|
|
136
|
+
if (!rateLimit2.allowed) {
|
|
137
|
+
return {
|
|
138
|
+
content: [{
|
|
139
|
+
type: 'text',
|
|
140
|
+
text: JSON.stringify({
|
|
141
|
+
error: 'Daily scan limit reached',
|
|
142
|
+
code: 'ERR_RATE_LIMIT',
|
|
143
|
+
resetsAt: new Date(rateLimit2.resetsAt).toISOString()
|
|
144
|
+
}, null, 2)
|
|
145
|
+
}],
|
|
146
|
+
isError: true
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const fixResults = await scan(args.url, {
|
|
151
|
+
timeout: (args.timeout || 10) * 1000,
|
|
152
|
+
tier: license.tier,
|
|
153
|
+
full: false
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
recordScan();
|
|
157
|
+
|
|
158
|
+
// Collect issues and generate structured fix data
|
|
159
|
+
const issues = [];
|
|
160
|
+
for (const [sectionName, section] of Object.entries(fixResults.sections)) {
|
|
161
|
+
for (const check of section.checks) {
|
|
162
|
+
if ((check.status === 'fail' || check.status === 'warn') && check.recommendation) {
|
|
163
|
+
issues.push({
|
|
164
|
+
section: sectionName,
|
|
165
|
+
name: check.name,
|
|
166
|
+
status: check.status,
|
|
167
|
+
recommendation: check.recommendation
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extract headers from issues
|
|
174
|
+
const headers = {};
|
|
175
|
+
for (const issue of issues) {
|
|
176
|
+
const rec = issue.recommendation;
|
|
177
|
+
if (rec.includes('Strict-Transport-Security')) headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload';
|
|
178
|
+
if (rec.includes('Content-Security-Policy')) headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'";
|
|
179
|
+
if (rec.includes('X-Content-Type-Options')) headers['X-Content-Type-Options'] = 'nosniff';
|
|
180
|
+
if (rec.includes('X-Frame-Options')) headers['X-Frame-Options'] = 'DENY';
|
|
181
|
+
if (rec.includes('Referrer-Policy')) headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
|
|
182
|
+
if (rec.includes('Permissions-Policy')) headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()';
|
|
183
|
+
if (rec.includes('Cross-Origin-Opener-Policy')) headers['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
184
|
+
if (rec.includes('Cross-Origin-Resource-Policy')) headers['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const hasSSL = issues.some(i => i.section === 'ssl');
|
|
188
|
+
|
|
189
|
+
// Build platform-specific config snippet
|
|
190
|
+
let configSnippet = '';
|
|
191
|
+
const p = args.platform;
|
|
192
|
+
if (p === 'nginx') {
|
|
193
|
+
const headerLines = Object.entries(headers).map(([h, v]) => ` add_header ${h} "${v}" always;`).join('\n');
|
|
194
|
+
configSnippet = `server {\n # ... your existing config ...\n\n${headerLines}\n}`;
|
|
195
|
+
if (hasSSL) configSnippet += '\n\n# SSL/TLS\nssl_protocols TLSv1.2 TLSv1.3;\nssl_prefer_server_ciphers on;';
|
|
196
|
+
} else if (p === 'apache') {
|
|
197
|
+
const headerLines = Object.entries(headers).map(([h, v]) => ` Header always set ${h} "${v}"`).join('\n');
|
|
198
|
+
configSnippet = `<IfModule mod_headers.c>\n${headerLines}\n</IfModule>`;
|
|
199
|
+
} else if (p === 'caddy') {
|
|
200
|
+
const headerLines = Object.entries(headers).map(([h, v]) => ` ${h} "${v}"`).join('\n');
|
|
201
|
+
configSnippet = `header {\n${headerLines}\n}`;
|
|
202
|
+
} else if (p === 'cloudflare') {
|
|
203
|
+
configSnippet = Object.entries(headers).map(([h, v]) => `Set "${h}" to "${v}"`).join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fixData = {
|
|
207
|
+
url: args.url,
|
|
208
|
+
platform: args.platform,
|
|
209
|
+
issueCount: issues.length,
|
|
210
|
+
issues: issues.map(i => ({ section: i.section, name: i.name, status: i.status, recommendation: i.recommendation })),
|
|
211
|
+
headers,
|
|
212
|
+
hasSSLIssues: hasSSL,
|
|
213
|
+
configSnippet,
|
|
214
|
+
instructions: {
|
|
215
|
+
nginx: 'Add to server {} block, then: sudo nginx -t && sudo systemctl reload nginx',
|
|
216
|
+
apache: 'Add to .htaccess or site config, then: sudo apachectl configtest && sudo systemctl reload apache2',
|
|
217
|
+
caddy: 'Add to Caddyfile site block, then: sudo systemctl reload caddy',
|
|
218
|
+
cloudflare: 'Dashboard → Rules → Transform Rules → Modify Response Header'
|
|
219
|
+
}[args.platform]
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
content: [{
|
|
224
|
+
type: 'text',
|
|
225
|
+
text: JSON.stringify(fixData, null, 2)
|
|
226
|
+
}]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case 'get_schema': {
|
|
231
|
+
return {
|
|
232
|
+
content: [{
|
|
233
|
+
type: 'text',
|
|
234
|
+
text: JSON.stringify(getSchema(), null, 2)
|
|
235
|
+
}]
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
default:
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
242
|
+
isError: true
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return {
|
|
247
|
+
content: [{
|
|
248
|
+
type: 'text',
|
|
249
|
+
text: JSON.stringify({ error: err.message, code: 'ERR_SCAN' }, null, 2)
|
|
250
|
+
}],
|
|
251
|
+
isError: true
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const transport = new StdioServerTransport();
|
|
257
|
+
await server.connect(transport);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { startMCPServer };
|