correctover-scan 2.1.2 → 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/LICENSE +68 -0
- package/README.md +205 -29
- package/core/bundle-scanner.js +775 -0
- package/core/license.js +128 -0
- package/core/report.js +227 -0
- package/core/scanner.js +239 -0
- package/index.js +628 -14
- package/package.json +38 -26
- package/cli.js +0 -226
package/index.js
CHANGED
|
@@ -1,20 +1,634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
/**
|
|
2
|
-
* correctover-scan
|
|
3
|
-
*
|
|
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
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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();
|