correctover-scan 2.1.3 → 3.0.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/index.js CHANGED
@@ -1,20 +1,634 @@
1
+ #!/usr/bin/env node
1
2
  /**
2
- * correctover-scan v2 compatibility shim.
3
- * Forwards to maintained correctover runtime.
3
+ * correctover-scan — CCS Security Scanner for MCP Configurations
4
+ * Usage: npx correctover-scan [config-file] [options]
5
+ *
6
+ * Scans MCP configuration files for security issues.
7
+ * Maps to OWASP AISVS 1.0 and Chinese National Standard《智能体应用安全基本要求》
4
8
  */
5
- 'use strict';
6
9
 
7
- const correctover = require('correctover');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { runScan, parseConfig, KNOWN_CONFIG_PATHS } = require('./core/scanner');
13
+ const { runBundleScan, discoverBundleFiles } = require('./core/bundle-scanner');
14
+ const { recordCall, getUpgradeMessage } = require('./core/license');
15
+ const { formatHTMLReport, normalizeConfigFindings, normalizeBundleFindings, failHint } = require('./core/report');
8
16
 
9
- const migrationNotice = [
10
- 'correctover-scan v1.x has been retired.',
11
- 'Use correctover for maintained MCP/tool-call scanning:',
12
- ' npm install correctover',
13
- ' const { MCPSecurityValidator } = require("correctover");'
14
- ].join('\n');
17
+ const VERSION = '1.7.3';
18
+ const PRODUCT = 'correctover-scan';
19
+ const JS_EXT = new Set(['.js', '.mjs', '.cjs', '.ts']);
15
20
 
16
- module.exports = {
17
- ...correctover,
18
- migrationNotice,
19
- MIGRATION_NOTICE: migrationNotice
21
+ // Colors
22
+ const c = {
23
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
24
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m',
25
+ cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m',
26
+ bgRed: '\x1b[41m', bgGreen: '\x1b[42m', bgYellow: '\x1b[43m',
20
27
  };
28
+
29
+ const icons = { pass: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' };
30
+ const sevColors = { critical: c.red, high: c.yellow, medium: c.cyan, low: c.gray };
31
+ const sevLabels = { critical: 'CRITICAL', high: 'HIGH', medium: 'MEDIUM', low: 'LOW' };
32
+
33
+ function printBanner(stream) {
34
+ const out = stream === 'stderr' ? console.error : console.log;
35
+ out(`
36
+ ${c.bold}${c.blue} ╔══════════════════════════════════════════╗
37
+ ║ CCS Security Scanner by Correctover ║
38
+ ║ AI Agent Runtime Assurance ║
39
+ ╚══════════════════════════════════════════╝${c.reset}
40
+ ${c.dim}v${VERSION} | OWASP AISVS 1.0 | 14 checks · MCP config + JS bundle modes${c.reset}
41
+ `);
42
+ }
43
+
44
+ function findConfigFiles(dir) {
45
+ const found = [];
46
+ for (const relPath of KNOWN_CONFIG_PATHS) {
47
+ const fullPath = path.resolve(dir, relPath);
48
+ if (fs.existsSync(fullPath)) {
49
+ found.push(fullPath);
50
+ }
51
+ }
52
+ // Also scan for any mcp*.json or mcp*.yaml in common locations
53
+ const scanDirs = ['.', '.cursor', '.claude', '.vscode', '.mcp', 'config'];
54
+ for (const d of scanDirs) {
55
+ const dirPath = path.resolve(dir, d);
56
+ if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
57
+ try {
58
+ const files = fs.readdirSync(dirPath);
59
+ for (const f of files) {
60
+ if (/^mcp[_-]?.*\.(json|yaml|yml)$/.test(f)) {
61
+ const fp = path.join(dirPath, f);
62
+ if (!found.includes(fp)) found.push(fp);
63
+ }
64
+ }
65
+ } catch (e) {}
66
+ }
67
+ }
68
+ return found;
69
+ }
70
+
71
+ function formatResults(results, stats, filename) {
72
+ const lines = [];
73
+
74
+ // File header
75
+ lines.push(`${c.bold}📄 ${filename}${c.reset}`);
76
+ lines.push('─'.repeat(50));
77
+
78
+ // Score
79
+ const scoreColor = stats.score >= 80 ? c.green : stats.score >= 60 ? c.yellow : c.red;
80
+ lines.push(`\n${c.bold}Security Score: ${scoreColor}${c.bold}${stats.score}/100${c.reset}`);
81
+ lines.push(` ${c.green}✓ ${stats.pass} passed${c.reset} ${c.yellow}⚠ ${stats.warn} warnings${c.reset} ${c.red}✗ ${stats.fail} critical${c.reset} ${c.blue}ℹ ${stats.info} info${c.reset}\n`);
82
+
83
+ // Group by category
84
+ const categories = {};
85
+ for (const r of results) {
86
+ if (!categories[r.category]) categories[r.category] = [];
87
+ categories[r.category].push(r);
88
+ }
89
+
90
+ for (const [cat, checks] of Object.entries(categories)) {
91
+ lines.push(`${c.dim}── ${cat} ──${c.reset}`);
92
+ for (const r of checks) {
93
+ const icon = icons[r.status];
94
+ const sevColor = sevColors[r.severity];
95
+ const sevLabel = sevLabels[r.severity];
96
+ const statusStr = r.status === 'pass' ? c.green + 'PASS' : r.status === 'fail' ? c.red + 'FAIL' : r.status === 'warn' ? c.yellow + 'WARN' : c.blue + 'INFO';
97
+ lines.push(` ${icon} ${r.name} ${c.reset}[${statusStr}${c.reset}] ${c.dim}${r.aisvs}${c.reset}`);
98
+ }
99
+ lines.push('');
100
+ }
101
+
102
+ // Recommendations
103
+ const issues = results.filter(r => r.status === 'fail' || r.status === 'warn');
104
+ if (issues.length > 0) {
105
+ lines.push(`${c.bold}${c.yellow}Recommendations:${c.reset}\n`);
106
+ for (const r of issues) {
107
+ const icon = r.status === 'fail' ? '🔴' : '🟡';
108
+ lines.push(` ${icon} ${c.bold}${r.name}${c.reset}`);
109
+ lines.push(` ${c.gray}${r.fix}${c.reset}\n`);
110
+ }
111
+ } else {
112
+ lines.push(`${c.green}${c.bold}🎉 All checks passed! Your MCP configuration is secure.${c.reset}\n`);
113
+ }
114
+
115
+ // CTA
116
+ lines.push('');
117
+ lines.push(`${c.dim}── Upgrade ─────────────────────────────────${c.reset}`);
118
+ if (stats.fail > 0 || stats.score < 60) {
119
+ lines.push(`${c.yellow}${c.bold}⚡ Score too low? CCS Pro generates formal compliance reports.${c.reset}`);
120
+ lines.push(`${c.dim} Enterprise: real-time runtime protection + Token guarantee${c.reset}`);
121
+ lines.push(`${c.cyan} → Upgrade: https://correctover.com/checkout${c.reset}`);
122
+ } else {
123
+ lines.push(`${c.green}✓ Good score! Get a formal compliance certificate with CCS Pro.${c.reset}`);
124
+ lines.push(`${c.dim} Audit reports · Team dashboard · Custom rules · SOC 2 ready${c.reset}`);
125
+ lines.push(`${c.cyan} → https://correctover.com/checkout${c.reset}`);
126
+ }
127
+ lines.push(`${c.dim}Enterprise: runtime SDK + Token guarantee → https://correctover.com${c.reset}`);
128
+ lines.push('');
129
+ lines.push(`${c.dim}Web: https://correctover.com/scan/ | GitHub: https://github.com/DSHCorrectover${c.reset}`);
130
+
131
+ // Manual audit CTA
132
+ lines.push('');
133
+ lines.push(`${c.dim}Free automated scan covers surface-level checks. A manual Correctover audit goes deeper: 116 semantic intent rules, 5-day turnaround, findings grounded in real MCP ecosystem CVEs. First customers: if we find no critical-severity issue, you pay nothing. \u2192 ${c.cyan}https://dshcorrectover.github.io/agent-audit/${c.reset}`);
134
+
135
+ return lines.join('\n');
136
+ }
137
+
138
+ /* ------------------------------------------------------------------ */
139
+ /* Bundle mode output */
140
+ /* ------------------------------------------------------------------ */
141
+
142
+ function formatBundleResults(bundle, baseLabel) {
143
+ const lines = [];
144
+ const stats = bundle.stats;
145
+ lines.push(`${c.bold}📦 Bundle/code scan: ${baseLabel}${c.reset}`);
146
+ lines.push(` ${c.dim}files: ${bundle.files.length} · code-layer checks: ${bundle.checks.length} (12 automatic verdicts + 5 semi-automatic signal enums; check ids continue the 14-check config scheme)${c.reset}`);
147
+ lines.push('─'.repeat(50));
148
+
149
+ const scoreColor = stats.score >= 80 ? c.green : stats.score >= 60 ? c.yellow : c.red;
150
+ lines.push(`\n${c.bold}Security Score: ${scoreColor}${c.bold}${stats.score}/100${c.reset}`);
151
+ lines.push(` ${c.green}✓ ${stats.pass} checks passed${c.reset} ${c.yellow}⚠ ${stats.warn} checks with warnings${c.reset} ${c.red}✗ ${stats.fail} checks with critical findings${c.reset} ${c.blue}ℹ ${stats.info} checks info-only${c.reset}`);
152
+ lines.push(` ${c.dim}findings: ${stats.findings.fail} fail · ${stats.findings.warn} warn · ${stats.findings.info} info · ${stats.findings.suppressed} suppressed by context heuristics${c.reset}\n`);
153
+
154
+ for (const fr of bundle.files) {
155
+ const active = [];
156
+ const suppressedCount = fr.results.reduce((n, r) => n + r.findings.filter(f => f.suppressed).length, 0);
157
+ for (const r of fr.results) {
158
+ for (const f of r.findings) {
159
+ if (!f.suppressed) active.push({ check: r, f });
160
+ }
161
+ }
162
+ if (active.length === 0) {
163
+ lines.push(`${c.green}✅ ${fr.file} — no fail/warn findings (${suppressedCount} known-benign signals suppressed, see JSON for detail)${c.reset}\n`);
164
+ continue;
165
+ }
166
+ lines.push(`${c.bold}📄 ${fr.file}${c.reset}`);
167
+ for (const { check, f } of active) {
168
+ const icon = icons[f.severity === 'fail' ? 'fail' : f.severity === 'warn' ? 'warn' : 'info'];
169
+ const statusStr = f.severity === 'fail' ? c.red + 'FAIL' : f.severity === 'warn' ? c.yellow + 'WARN' : c.blue + 'INFO';
170
+ lines.push(` ${icon} ${c.dim}[${check.id} · ${check.aisvs}]${c.reset} ${statusStr}${c.reset} ${c.bold}L${f.line}${c.reset}`);
171
+ lines.push(` ${f.message}`);
172
+ if (f.snippet) lines.push(` ${c.gray}${f.snippet}${c.reset}`);
173
+ lines.push('');
174
+ }
175
+ }
176
+
177
+ // Semi-automatic checks summary
178
+ const semiIds = ['budget-limit', 'logging', 'version-pin', 'error-handling', 'output-validation'];
179
+ lines.push(`${c.dim}── Semi-automatic checks (signals enumerated; conclusion needs manual review) ──${c.reset}`);
180
+ for (const id of semiIds) {
181
+ let n = 0;
182
+ for (const fr of bundle.files) {
183
+ const r = fr.results.find(x => x.id === id);
184
+ if (r) n += r.findings.length;
185
+ }
186
+ lines.push(` ${c.blue}ℹ${c.reset} ${c.dim}${id}: ${n} signal(s) listed in JSON output${c.reset}`);
187
+ }
188
+
189
+ lines.push('');
190
+ lines.push(`${c.dim}Note: bundle mode is signal scanning over published/minified code. It locates every${c.reset}`);
191
+ lines.push(`${c.dim}risk-bearing string/API call with file+line; it does not prove reachability or intent —${c.reset}`);
192
+ lines.push(`${c.dim}fail/warn items and the semi-automatic signals are the input for manual deep review.${c.reset}`);
193
+
194
+ lines.push('');
195
+ lines.push(`${c.dim}── Upgrade ─────────────────────────────────${c.reset}`);
196
+ if (stats.fail > 0 || stats.score < 60) {
197
+ lines.push(`${c.yellow}${c.bold}⚡ Critical findings present. CCS Pro generates formal compliance reports.${c.reset}`);
198
+ lines.push(`${c.dim} Enterprise: real-time runtime protection + Token guarantee${c.reset}`);
199
+ lines.push(`${c.cyan} → Upgrade: https://correctover.com/checkout${c.reset}`);
200
+ } else {
201
+ lines.push(`${c.green}✓ No critical signals! Get a formal compliance certificate with CCS Pro.${c.reset}`);
202
+ lines.push(`${c.cyan} → https://correctover.com/checkout${c.reset}`);
203
+ }
204
+ lines.push(`${c.dim}Manual bundle audit (116-rule deep review): https://dshcorrectover.github.io/agent-audit/${c.reset}`);
205
+ lines.push('');
206
+ lines.push(`${c.dim}Web: https://correctover.com/scan/ | GitHub: https://github.com/DSHCorrectover${c.reset}`);
207
+ return lines.join('\n');
208
+ }
209
+
210
+ function formatBundleJSON(bundle, target) {
211
+ return JSON.stringify({
212
+ scanner: 'correctover-scan', version: VERSION, mode: 'bundle', target,
213
+ stats: bundle.stats,
214
+ files: bundle.files.map(fr => ({
215
+ file: fr.file,
216
+ results: fr.results.map(r => ({
217
+ id: r.id, name: r.name, category: r.category, aisvs: r.aisvs,
218
+ severity: r.severity, status: r.status, fix: r.fix,
219
+ findings: r.findings.map(f => ({ line: f.line, severity: f.severity, suppressed: f.suppressed, message: f.message, snippet: f.snippet })),
220
+ })),
221
+ })),
222
+ }, null, 2);
223
+ }
224
+
225
+ function formatBundleSARIF(bundle, target) {
226
+ const rules = [];
227
+ const results = [];
228
+ for (const fr of bundle.files) {
229
+ for (const r of fr.results) {
230
+ if (!rules.some(x => x.id === r.id)) {
231
+ rules.push({
232
+ id: r.id, name: r.name,
233
+ shortDescription: { text: `${r.category}: ${r.name}` },
234
+ helpUri: `https://correctover.com/scan/#check-${r.id}`,
235
+ properties: { aisvs: r.aisvs, severity: r.severity, mode: 'bundle' },
236
+ });
237
+ }
238
+ for (const f of r.findings) {
239
+ if (f.suppressed || f.severity === 'info') continue;
240
+ results.push({
241
+ ruleId: r.id,
242
+ level: f.severity === 'fail' ? 'error' : 'warning',
243
+ message: { text: f.message },
244
+ locations: [{ physicalLocation: {
245
+ artifactLocation: { uri: fr.file },
246
+ region: f.line ? { startLine: f.line } : undefined,
247
+ } }],
248
+ });
249
+ }
250
+ }
251
+ }
252
+ return {
253
+ version: '2.1.0',
254
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
255
+ runs: [{
256
+ tool: { driver: { name: 'correctover-scan', version: VERSION, informationUri: 'https://correctover.com', rules } },
257
+ results,
258
+ }],
259
+ };
260
+ }
261
+
262
+ function formatSARIF(results, filename) {
263
+ return {
264
+ version: '2.1.0',
265
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
266
+ runs: [{
267
+ tool: {
268
+ driver: {
269
+ name: 'correctover-scan',
270
+ version: VERSION,
271
+ informationUri: 'https://correctover.com',
272
+ rules: results.map(r => ({
273
+ id: r.id,
274
+ name: r.name,
275
+ shortDescription: { text: `${r.category}: ${r.name}` },
276
+ helpUri: `https://correctover.com/scan/#check-${r.id}`,
277
+ properties: { aisvs: r.aisvs, severity: r.severity }
278
+ }))
279
+ }
280
+ },
281
+ results: results.filter(r => r.status === 'fail' || r.status === 'warn').map(r => ({
282
+ ruleId: r.id,
283
+ level: r.status === 'fail' ? 'error' : 'warning',
284
+ message: { text: r.fix },
285
+ locations: [{ physicalLocation: { artifactLocation: { uri: filename } } }]
286
+ }))
287
+ }]
288
+ };
289
+ }
290
+
291
+ function formatJSON(results, stats, filename) {
292
+ return JSON.stringify({ scanner: 'correctover-scan', version: VERSION, file: filename, stats, results }, null, 2);
293
+ }
294
+
295
+ /* ------------------------------------------------------------------ */
296
+ /* HTML shareable report (verification-as-acquisition funnel) */
297
+ /* ------------------------------------------------------------------ */
298
+
299
+ function defaultReportPath() {
300
+ const d = new Date();
301
+ const pad = (n) => String(n).padStart(2, '0');
302
+ const base = `correctover-scan-report-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
303
+ let p = path.join(process.cwd(), `${base}.html`);
304
+ let n = 2;
305
+ while (fs.existsSync(p)) p = path.join(process.cwd(), `${base}-${n++}.html`);
306
+ return p;
307
+ }
308
+
309
+ function runBundleMode(target, format, outFile) {
310
+ // All diagnostics/banners go to stderr so that stdout carries only the
311
+ // machine-readable payload for json/sarif consumers.
312
+ const diag = (...a) => console.error(...a);
313
+ printBanner(format === 'text' && !outFile ? 'stdout' : 'stderr');
314
+
315
+ // License check (same 50/day free tier counting as config mode)
316
+ const status = recordCall(PRODUCT);
317
+ if (!status.authorized) {
318
+ diag(getUpgradeMessage(status));
319
+ process.exit(1);
320
+ }
321
+ if (status.tier === 'free') {
322
+ diag(`${c.dim}📊 Free tier: ${status.calls_remaining} scans remaining today (${status.calls_today}/${status.limit})${c.reset}`);
323
+ diag(`${c.dim} Upgrade: https://correctover.com/checkout${c.reset}\n`);
324
+ } else if (status.tier === 'pro') {
325
+ diag(`${c.green}✅ Pro license active — unlimited scans${c.reset}\n`);
326
+ }
327
+
328
+ if (!fs.existsSync(target)) {
329
+ diag(`${c.red}Error: path not found: ${target}${c.reset}`);
330
+ process.exit(1);
331
+ }
332
+
333
+ const abs = path.resolve(target);
334
+ const isDir = fs.statSync(abs).isDirectory();
335
+ const baseDir = isDir ? abs : path.dirname(abs);
336
+ let files;
337
+ try {
338
+ files = discoverBundleFiles(abs);
339
+ } catch (e) {
340
+ diag(`${c.red}Error discovering bundle files: ${e.message}${c.reset}`);
341
+ process.exit(1);
342
+ }
343
+ if (files.length === 0) {
344
+ diag(`${c.yellow}No JavaScript files (.js/.mjs/.cjs) found under ${target}.${c.reset}`);
345
+ process.exit(0);
346
+ }
347
+ diag(`${c.dim}Bundle mode: ${files.length} JS file(s) under ${target}${c.reset}\n`);
348
+
349
+ const bundle = runBundleScan(files, baseDir);
350
+ const label = path.relative(process.cwd(), abs) || abs;
351
+
352
+ let payload;
353
+ let htmlDefault = false;
354
+ if (format === 'json') {
355
+ payload = formatBundleJSON(bundle, label);
356
+ } else if (format === 'sarif') {
357
+ payload = JSON.stringify(formatBundleSARIF(bundle, label), null, 2);
358
+ } else if (format === 'html') {
359
+ const labelCounts = {
360
+ pass: bundle.stats.pass, warn: bundle.stats.warn, fail: bundle.stats.fail, info: bundle.stats.info,
361
+ score: bundle.stats.score,
362
+ };
363
+ payload = formatHTMLReport({
364
+ mode: 'bundle/code scan', target: label,
365
+ items: normalizeBundleFindings(bundle), totals: labelCounts,
366
+ checksLabel: '12 automatic + 5 semi-automatic code-layer checks',
367
+ version: VERSION,
368
+ });
369
+ if (!outFile) { outFile = defaultReportPath(); htmlDefault = true; }
370
+ } else {
371
+ payload = formatBundleResults(bundle, label);
372
+ }
373
+ if (outFile) {
374
+ try {
375
+ fs.writeFileSync(outFile, payload + '\n');
376
+ diag(`${c.green}✅ Report written to ${outFile}${c.reset}`);
377
+ if (htmlDefault) {
378
+ diag(`${c.dim}Shareable HTML report — forward it to your team. ${failHint(bundle.stats.findings.fail)}${c.reset}`);
379
+ }
380
+ } catch (e) {
381
+ console.error(`${c.red}Error writing output file: ${e.message}${c.reset}`);
382
+ process.exit(1);
383
+ }
384
+ } else {
385
+ console.log(payload);
386
+ }
387
+
388
+ // Exit code: 1 if any check has fail-level findings
389
+ if (bundle.stats.findings.fail > 0) process.exit(1);
390
+ }
391
+
392
+ function main() {
393
+ const args = process.argv.slice(2);
394
+ let configPath = null;
395
+ let bundlePath = null;
396
+ let format = 'text'; // text, json, sarif
397
+ let scanDir = process.cwd();
398
+ let recursive = false;
399
+ let outFile = null;
400
+
401
+ // Parse args
402
+ for (let i = 0; i < args.length; i++) {
403
+ const arg = args[i];
404
+ if (arg === '--version' || arg === '-v') {
405
+ console.log(`correctover-scan v${VERSION}`);
406
+ process.exit(0);
407
+ } else if (arg === '--help' || arg === '-h') {
408
+ console.log(`
409
+ Usage: correctover-scan [target] [options]
410
+
411
+ MCP config mode (default for .json/.yaml/mcp.json):
412
+ correctover-scan mcp.json Scan a specific MCP config
413
+ correctover-scan -d ./project Auto-detect configs in directory
414
+ correctover-scan -d ./project -r Recursively find config files
415
+
416
+ Bundle / published-package code mode (v1.4.0+):
417
+ correctover-scan --bundle ./pkg Audit an unpacked npm package
418
+ (reads package.json entry + all .js/.mjs/.cjs)
419
+ correctover-scan --bundle dist/app.min.js Audit a single minified/bundled JS file
420
+ correctover-scan ./cli.js Auto-detected: .js/.mjs/.cjs target → bundle mode
421
+ correctover-scan --bundle ./pkg -f sarif SARIF with file+line locations
422
+
423
+ Options:
424
+ --bundle <path> Explicitly run bundle/code scan on a JS file or directory
425
+ -f, --format <type> Output format: text, json, sarif, html (default: text)
426
+ html writes a self-contained shareable report file
427
+ (correctover-scan-report-<date>.html if no -o given)
428
+ -d, --dir <path> Directory to scan for MCP configs (default: cwd)
429
+ -r, --recursive Recursively find MCP config files
430
+ -o, --output <file> Write the report to a file (text/json/sarif) instead of stdout
431
+ -v, --version Show version
432
+ -h, --help Show this help
433
+
434
+ Bundle mode performs signal scanning of shipped/minified code (hardcoded secrets,
435
+ plaintext endpoints, cloud metadata/SSRF, shell:true/exec, eval/Function/vm, env
436
+ credential flow, permission gates, MCP transport auth, timeouts, kill switch,
437
+ sandbox, input validation) with context-based false-positive suppression. It
438
+ locates signals at file+line; reachability/intent conclusions need manual review.
439
+
440
+ Examples:
441
+ npx correctover-scan Auto-detect MCP configs in cwd
442
+ npx correctover-scan --bundle . Audit the current package's code
443
+ `);
444
+ process.exit(0);
445
+ } else if (arg === '--format' || arg === '-f') {
446
+ format = args[++i];
447
+ } else if (arg === '--dir' || arg === '-d') {
448
+ scanDir = args[++i];
449
+ } else if (arg === '--recursive' || arg === '-r') {
450
+ recursive = true;
451
+ } else if (arg === '--bundle' || arg === '-b') {
452
+ bundlePath = args[++i];
453
+ } else if (arg === '--output' || arg === '-o') {
454
+ outFile = args[++i] || null;
455
+ } else if (!arg.startsWith('-')) {
456
+ configPath = arg;
457
+ }
458
+ }
459
+
460
+ // Auto-detect bundle mode: positional .js/.mjs/.cjs file, or a directory
461
+ // that contains JS but no MCP config and looks like a package.
462
+ if (!bundlePath && configPath && fs.existsSync(configPath)) {
463
+ const st = fs.statSync(configPath);
464
+ if (st.isFile() && JS_EXT.has(path.extname(configPath).toLowerCase())) {
465
+ bundlePath = configPath;
466
+ configPath = null;
467
+ } else if (st.isDirectory() && !configPath.endsWith('.json')) {
468
+ // directory positional: prefer config mode only if a known config exists
469
+ const hasConfig = KNOWN_CONFIG_PATHS.some(rel => fs.existsSync(path.join(configPath, rel)));
470
+ if (!hasConfig) {
471
+ let hasJS = false;
472
+ try { hasJS = fs.readdirSync(configPath).some(f => JS_EXT.has(path.extname(f).toLowerCase())); } catch (e) {}
473
+ if (hasJS || fs.existsSync(path.join(configPath, 'package.json'))) {
474
+ bundlePath = configPath;
475
+ configPath = null;
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ if (bundlePath) {
482
+ return runBundleMode(bundlePath, format, outFile);
483
+ }
484
+
485
+ // In json/sarif mode all diagnostics go to stderr, keeping stdout clean.
486
+ const diag = (format === 'text' && !outFile) ? console.log : (...a) => console.error(...a);
487
+ printBanner(format === 'text' && !outFile ? 'stdout' : 'stderr');
488
+
489
+ // Text-mode payload buffer: when -o writes a report file, the report text is
490
+ // collected instead of being streamed to stdout (diagnostics stay on stderr).
491
+ const textBuffer = outFile ? [] : null;
492
+ const emit = textBuffer ? (s) => textBuffer.push(s) : (s) => console.log(s);
493
+
494
+ // License check
495
+ const status = recordCall(PRODUCT);
496
+ if (!status.authorized) {
497
+ diag(getUpgradeMessage(status));
498
+ process.exit(1);
499
+ }
500
+ if (status.tier === 'free') {
501
+ diag(`${c.dim}📊 Free tier: ${status.calls_remaining} scans remaining today (${status.calls_today}/${status.limit})${c.reset}`);
502
+ diag(`${c.dim} Upgrade: https://correctover.com/checkout${c.reset}\n`);
503
+ } else if (status.tier === 'pro') {
504
+ diag(`${c.green}✅ Pro license active — unlimited scans${c.reset}\n`);
505
+ }
506
+
507
+ const filesToScan = [];
508
+
509
+ if (configPath) {
510
+ // Specific file
511
+ if (!fs.existsSync(configPath)) {
512
+ console.error(`${c.red}Error: File not found: ${configPath}${c.reset}`);
513
+ process.exit(1);
514
+ }
515
+ filesToScan.push(configPath);
516
+ } else {
517
+ // Auto-detect
518
+ diag(`${c.dim}Auto-detecting MCP configs in ${scanDir}...${c.reset}\n`);
519
+ const found = findConfigFiles(scanDir);
520
+ if (recursive) {
521
+ // Walk directory tree
522
+ function walkDir(dir) {
523
+ try {
524
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
525
+ for (const entry of entries) {
526
+ if (entry.name.startsWith('.') && entry.name !== '.cursor' && entry.name !== '.claude' && entry.name !== '.vscode' && entry.name !== '.mcp') continue;
527
+ const fullPath = path.join(dir, entry.name);
528
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
529
+ walkDir(fullPath);
530
+ } else if (entry.isFile() && /mcp[_-]?.*\.(json|yaml|yml)$/.test(entry.name)) {
531
+ if (!found.includes(fullPath)) found.push(fullPath);
532
+ }
533
+ }
534
+ } catch (e) {}
535
+ }
536
+ walkDir(scanDir);
537
+ }
538
+ filesToScan.push(...found);
539
+ }
540
+
541
+ if (filesToScan.length === 0) {
542
+ diag(`${c.yellow}No MCP configuration files found.${c.reset}`);
543
+ diag(`${c.dim}Searched paths: ${KNOWN_CONFIG_PATHS.join(', ')}${c.reset}`);
544
+ diag(`\n${c.dim}Config mode: correctover-scan path/to/mcp.json${c.reset}`);
545
+ const cwdHasPkg = fs.existsSync(path.join(scanDir, 'package.json'));
546
+ if (cwdHasPkg) {
547
+ diag(`${c.cyan}Detected package.json here — audit published/bundled code with: correctover-scan --bundle ${scanDir}${c.reset}`);
548
+ } else {
549
+ diag(`${c.dim}Audit an npm package / JS bundle instead: correctover-scan --bundle <path-to-package-or-js>${c.reset}`);
550
+ }
551
+ process.exit(0);
552
+ }
553
+
554
+ diag(`${c.dim}Found ${filesToScan.length} config file(s)${c.reset}\n`);
555
+
556
+ let totalPass = 0, totalWarn = 0, totalFail = 0, totalInfo = 0;
557
+ let allResults = [];
558
+
559
+ for (const fp of filesToScan) {
560
+ try {
561
+ const content = fs.readFileSync(fp, 'utf-8');
562
+ const config = parseConfig(content, fp);
563
+ const { results, stats } = runScan(config);
564
+ const relPath = path.relative(process.cwd(), fp) || fp;
565
+
566
+ if (format === 'text') {
567
+ emit(formatResults(results, stats, relPath));
568
+ emit('');
569
+ }
570
+
571
+ totalPass += stats.pass;
572
+ totalWarn += stats.warn;
573
+ totalFail += stats.fail;
574
+ totalInfo += stats.info;
575
+ allResults.push({ file: relPath, results, stats });
576
+ } catch (e) {
577
+ console.error(`${c.red}Error scanning ${fp}: ${e.message}${c.reset}\n`);
578
+ }
579
+ }
580
+
581
+ // JSON/SARIF/HTML output
582
+ let machinePayload = null;
583
+ let htmlDefault = false;
584
+ if (format === 'json') {
585
+ const output = allResults.map(r => formatJSON(r.results, r.stats, r.file));
586
+ machinePayload = output.join('\n');
587
+ } else if (format === 'sarif') {
588
+ const sarifResults = allResults.map(r => formatSARIF(r.results, r.file));
589
+ machinePayload = JSON.stringify(sarifResults.length === 1 ? sarifResults[0] : { runs: sarifResults.flatMap(s => s.runs) }, null, 2);
590
+ } else if (format === 'html') {
591
+ const totals = { pass: totalPass, warn: totalWarn, fail: totalFail, info: totalInfo };
592
+ const denom = (totalPass + totalWarn + totalFail + totalInfo) * 10;
593
+ totals.score = denom === 0 ? 100 : Math.round(((totalPass * 10 + totalWarn * 5 + totalInfo * 7) / denom) * 100);
594
+ machinePayload = formatHTMLReport({
595
+ mode: 'MCP config scan',
596
+ target: filesToScan.length === 1 ? path.relative(process.cwd(), filesToScan[0]) || filesToScan[0] : `${filesToScan.length} config file(s)`,
597
+ items: normalizeConfigFindings(allResults), totals,
598
+ checksLabel: '14 config checks mapped to OWASP AISVS 1.0',
599
+ version: VERSION,
600
+ });
601
+ if (!outFile) { outFile = defaultReportPath(); htmlDefault = true; }
602
+ }
603
+
604
+ // Summary
605
+ if (filesToScan.length > 1 && format === 'text') {
606
+ const totalScore = Math.round(((totalPass * 10 + totalWarn * 5 + totalInfo * 7) / ((totalPass + totalWarn + totalFail + totalInfo) * 10)) * 100);
607
+ emit(`${c.bold}═══ Summary ═══${c.reset}`);
608
+ emit(` Files scanned: ${filesToScan.length}`);
609
+ emit(` Total score: ${totalScore}/100`);
610
+ emit(` ${c.green}✓ ${totalPass}${c.reset} ${c.yellow}⚠ ${totalWarn}${c.reset} ${c.red}✗ ${totalFail}${c.reset} ${c.blue}ℹ ${totalInfo}${c.reset}`);
611
+ }
612
+
613
+ // -o: write the report payload to a file; otherwise stream to stdout
614
+ if (outFile) {
615
+ const payload = machinePayload || textBuffer.join('\n');
616
+ try {
617
+ fs.writeFileSync(outFile, payload + '\n');
618
+ diag(`${c.green}✅ Report written to ${outFile}${c.reset}`);
619
+ if (htmlDefault) {
620
+ diag(`${c.dim}Shareable HTML report — forward it to your team. ${failHint(totalFail)}${c.reset}`);
621
+ }
622
+ } catch (e) {
623
+ console.error(`${c.red}Error writing output file: ${e.message}${c.reset}`);
624
+ process.exit(1);
625
+ }
626
+ } else if (machinePayload) {
627
+ console.log(machinePayload);
628
+ }
629
+
630
+ // Exit code: 1 if any critical failures
631
+ if (totalFail > 0) process.exit(1);
632
+ }
633
+
634
+ main();