@mcp-shark/mcp-shark 1.5.13 → 1.7.2
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 +482 -56
- package/bin/mcp-shark.js +146 -52
- package/core/cli/AutoFixEngine.js +93 -0
- package/core/cli/ConfigScanner.js +193 -0
- package/core/cli/DataLoader.js +200 -0
- package/core/cli/DeclarativeRuleEngine.js +363 -0
- package/core/cli/DoctorCommand.js +218 -0
- package/core/cli/FixHandlers.js +222 -0
- package/core/cli/HtmlReportGenerator.js +203 -0
- package/core/cli/IdeConfigPaths.js +175 -0
- package/core/cli/ListCommand.js +255 -0
- package/core/cli/LockCommand.js +164 -0
- package/core/cli/LockDiffEngine.js +152 -0
- package/core/cli/RuleRegistryConfig.js +131 -0
- package/core/cli/ScanCommand.js +244 -0
- package/core/cli/ScanService.js +200 -0
- package/core/cli/SecretDetector.js +92 -0
- package/core/cli/SharkScoreCalculator.js +109 -0
- package/core/cli/ToolClassifications.js +51 -0
- package/core/cli/ToxicFlowAnalyzer.js +212 -0
- package/core/cli/UpdateCommand.js +188 -0
- package/core/cli/WalkthroughGenerator.js +195 -0
- package/core/cli/WatchCommand.js +129 -0
- package/core/cli/YamlRuleEngine.js +197 -0
- package/core/cli/data/rule-packs/aauth-visibility.json +117 -0
- package/core/cli/data/rule-packs/agentic-security-2026.json +180 -0
- package/core/cli/data/rule-packs/general-security.json +173 -0
- package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
- package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
- package/core/cli/data/rule-sources.json +5 -0
- package/core/cli/data/secret-patterns.json +18 -0
- package/core/cli/data/tool-classifications.json +111 -0
- package/core/cli/data/toxic-flow-rules.json +47 -0
- package/core/cli/index.js +23 -0
- package/core/cli/output/Banner.js +52 -0
- package/core/cli/output/Formatter.js +183 -0
- package/core/cli/output/JsonFormatter.js +106 -0
- package/core/cli/output/index.js +16 -0
- package/core/cli/secureRegistryFetch.js +157 -0
- package/core/cli/symbols.js +16 -0
- package/core/configs/environment.js +3 -1
- package/core/configs/index.js +3 -64
- package/core/container/DependencyContainer.js +4 -1
- package/core/mcp-server/index.js +4 -1
- package/core/mcp-server/server/external/all.js +10 -3
- package/core/mcp-server/server/external/config.js +62 -5
- package/core/models/RequestFilters.js +3 -0
- package/core/repositories/PacketRepository.js +16 -0
- package/core/services/AuditService.js +2 -0
- package/core/services/ConfigService.js +9 -1
- package/core/services/ConfigTransformService.js +34 -2
- package/core/services/RequestService.js +58 -5
- package/core/services/ServerManagementService.js +59 -4
- package/core/services/security/StaticRulesService.js +69 -13
- package/core/services/security/TrafficAnalysisService.js +19 -1
- package/core/services/security/TrafficToxicFlowService.js +154 -0
- package/core/services/security/aauthGraph.js +199 -0
- package/core/services/security/aauthParser.js +274 -0
- package/core/services/security/aauthSelfTest.js +346 -0
- package/core/services/security/index.js +2 -1
- package/core/services/security/rules/index.js +25 -59
- package/core/services/security/rules/scans/configPermissions.js +91 -0
- package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
- package/core/services/security/rules/scans/insecureTransport.js +148 -0
- package/core/services/security/rules/scans/missingContainment.js +123 -0
- package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
- package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
- package/core/services/security/toolsListFromTrafficParser.js +70 -0
- package/core/tui/App.js +144 -0
- package/core/tui/FindingsPanel.js +115 -0
- package/core/tui/FixPanel.js +132 -0
- package/core/tui/Header.js +51 -0
- package/core/tui/HelpBar.js +42 -0
- package/core/tui/ServersPanel.js +109 -0
- package/core/tui/ToxicFlowsPanel.js +100 -0
- package/core/tui/h.js +8 -0
- package/core/tui/index.js +11 -0
- package/core/tui/render.js +22 -0
- package/package.json +24 -16
- package/ui/dist/assets/index-D6zDrtMV.js +81 -0
- package/ui/dist/index.html +1 -1
- package/ui/server/controllers/AauthController.js +279 -0
- package/ui/server/controllers/RequestController.js +12 -1
- package/ui/server/controllers/SecurityFindingsController.js +46 -1
- package/ui/server/routes/aauth.js +18 -0
- package/ui/server/routes/requests.js +8 -1
- package/ui/server/routes/security.js +5 -1
- package/ui/server/setup.js +224 -6
- package/ui/server/swagger/paths/components.js +55 -0
- package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
- package/ui/server/swagger/paths.js +2 -2
- package/ui/server/swagger/swagger.js +5 -2
- package/ui/server.js +1 -1
- package/ui/src/App.jsx +26 -52
- package/ui/src/PacketFilters.jsx +31 -1
- package/ui/src/PacketList.jsx +2 -2
- package/ui/src/Security.jsx +10 -0
- package/ui/src/TabNavigation.jsx +8 -0
- package/ui/src/components/AAuthBadge.jsx +92 -0
- package/ui/src/components/AauthExplorer/AauthExplorerGraph.jsx +231 -0
- package/ui/src/components/AauthExplorer/AauthExplorerView.jsx +387 -0
- package/ui/src/components/AauthExplorer/NodeDetailPanel.jsx +272 -0
- package/ui/src/components/App/ActionMenu.jsx +4 -31
- package/ui/src/components/App/ApiDocsButton.jsx +0 -1
- package/ui/src/components/App/ShutdownButton.jsx +0 -1
- package/ui/src/components/App/useAppState.js +19 -26
- package/ui/src/components/DetailsTab/AAuthIdentitySection.jsx +119 -0
- package/ui/src/components/DetailsTab/RequestDetailsSection.jsx +2 -0
- package/ui/src/components/DetailsTab/ResponseDetailsSection.jsx +2 -0
- package/ui/src/components/DetectedPathsList.jsx +1 -5
- package/ui/src/components/FileInput.jsx +0 -1
- package/ui/src/components/PacketFilters/AAuthPostureFilter.jsx +81 -0
- package/ui/src/components/RequestRow/RequestRowMain.jsx +7 -1
- package/ui/src/components/Security/AAuthPosturePanel.jsx +360 -0
- package/ui/src/components/Security/ScannerContent.jsx +33 -1
- package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
- package/ui/src/components/Security/securityApi.js +15 -0
- package/ui/src/components/Security/useSecurity.js +60 -3
- package/ui/src/components/ServerControl.jsx +0 -1
- package/ui/src/components/TabNavigation/DesktopTabs.jsx +0 -11
- package/ui/src/components/TabNavigationIcons.jsx +5 -0
- package/ui/src/components/ViewModeTabs.jsx +0 -1
- package/ui/src/utils/animations.js +26 -9
- package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
- package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
- package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
- package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
- package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
- package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
- package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
- package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
- package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
- package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
- package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
- package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
- package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
- package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
- package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
- package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
- package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
- package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
- package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
- package/ui/dist/assets/index-CiCSDYf-.js +0 -97
- package/ui/server/routes/help.js +0 -44
- package/ui/server/swagger/paths/help.js +0 -82
- package/ui/src/HelpGuide/HelpGuideContent.jsx +0 -118
- package/ui/src/HelpGuide/HelpGuideFooter.jsx +0 -59
- package/ui/src/HelpGuide/HelpGuideHeader.jsx +0 -57
- package/ui/src/HelpGuide.jsx +0 -78
- package/ui/src/IntroTour.jsx +0 -154
- package/ui/src/components/App/HelpButton.jsx +0 -90
- package/ui/src/components/TourOverlay.jsx +0 -117
- package/ui/src/components/TourTooltip/TourTooltipButtons.jsx +0 -120
- package/ui/src/components/TourTooltip/TourTooltipHeader.jsx +0 -71
- package/ui/src/components/TourTooltip/TourTooltipIcons.jsx +0 -54
- package/ui/src/components/TourTooltip/useTooltipPosition.js +0 -135
- package/ui/src/components/TourTooltip.jsx +0 -91
- package/ui/src/config/tourSteps.jsx +0 -140
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor Command
|
|
3
|
+
* Quick environment health check covering IDE configs,
|
|
4
|
+
* environment, security posture, and MCP SDK status
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import kleur from 'kleur';
|
|
10
|
+
import { scanIdeConfigs } from './ConfigScanner.js';
|
|
11
|
+
import { S } from './symbols.js';
|
|
12
|
+
|
|
13
|
+
const LOCKFILE_NAME = '.mcp-shark.lock';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Execute the doctor command
|
|
17
|
+
*/
|
|
18
|
+
export function executeDoctor() {
|
|
19
|
+
console.log('');
|
|
20
|
+
console.log(kleur.bold(' mcp-shark doctor'));
|
|
21
|
+
console.log('');
|
|
22
|
+
|
|
23
|
+
const checks = {
|
|
24
|
+
passed: 0,
|
|
25
|
+
warnings: 0,
|
|
26
|
+
failures: 0,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
runIdeChecks(checks);
|
|
30
|
+
runEnvironmentChecks(checks);
|
|
31
|
+
runSecurityChecks(checks);
|
|
32
|
+
|
|
33
|
+
console.log('');
|
|
34
|
+
const summary = [
|
|
35
|
+
kleur.green(`${checks.passed} passed`),
|
|
36
|
+
kleur.yellow(`${checks.warnings} warnings`),
|
|
37
|
+
kleur.red(`${checks.failures} failures`),
|
|
38
|
+
].join(kleur.dim(` ${S.dot} `));
|
|
39
|
+
|
|
40
|
+
console.log(` ${summary}`);
|
|
41
|
+
console.log('');
|
|
42
|
+
|
|
43
|
+
return checks.failures > 0 ? 1 : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check IDE installations and configurations
|
|
48
|
+
*/
|
|
49
|
+
function runIdeChecks(checks) {
|
|
50
|
+
console.log(kleur.bold(' IDE Installations'));
|
|
51
|
+
|
|
52
|
+
const ideResults = scanIdeConfigs();
|
|
53
|
+
|
|
54
|
+
for (const ide of ideResults) {
|
|
55
|
+
if (ide.found) {
|
|
56
|
+
const details = `${ide.displayPath}, ${ide.serverCount} servers`;
|
|
57
|
+
printPass(ide.name, details);
|
|
58
|
+
checks.passed += 1;
|
|
59
|
+
} else {
|
|
60
|
+
printInfo(ide.name, 'not found');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check Node.js environment
|
|
69
|
+
*/
|
|
70
|
+
function runEnvironmentChecks(checks) {
|
|
71
|
+
console.log(kleur.bold(' Environment'));
|
|
72
|
+
|
|
73
|
+
const nodeVersion = process.version;
|
|
74
|
+
const nodeMajor = Number.parseInt(nodeVersion.slice(1).split('.')[0], 10);
|
|
75
|
+
|
|
76
|
+
if (nodeMajor >= 20) {
|
|
77
|
+
printPass(`Node.js ${nodeVersion}`, 'supported');
|
|
78
|
+
checks.passed += 1;
|
|
79
|
+
} else {
|
|
80
|
+
printFail(`Node.js ${nodeVersion}`, 'requires >= 20.0.0');
|
|
81
|
+
checks.failures += 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
printPass(`Platform: ${process.platform} ${process.arch}`, '');
|
|
85
|
+
checks.passed += 1;
|
|
86
|
+
|
|
87
|
+
console.log('');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check security-related configurations
|
|
92
|
+
*/
|
|
93
|
+
function runSecurityChecks(checks) {
|
|
94
|
+
console.log(kleur.bold(' Security'));
|
|
95
|
+
|
|
96
|
+
const ideResults = scanIdeConfigs();
|
|
97
|
+
const foundIdes = ideResults.filter((ide) => ide.found);
|
|
98
|
+
|
|
99
|
+
for (const ide of foundIdes) {
|
|
100
|
+
checkFilePermissions(ide, checks);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
checkLockfile(checks);
|
|
104
|
+
checkDuplicateToolNames(foundIdes, checks);
|
|
105
|
+
|
|
106
|
+
console.log('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if config files have overly permissive permissions
|
|
111
|
+
*/
|
|
112
|
+
function checkFilePermissions(ide, checks) {
|
|
113
|
+
if (process.platform === 'win32') {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!ide.permissions) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const perms = Number.parseInt(ide.permissions, 8);
|
|
121
|
+
const worldReadable = (perms & 0o004) !== 0;
|
|
122
|
+
const groupReadable = (perms & 0o040) !== 0;
|
|
123
|
+
|
|
124
|
+
if (worldReadable || groupReadable) {
|
|
125
|
+
const reason = worldReadable ? 'world-readable' : 'group-readable';
|
|
126
|
+
printWarn(`${ide.displayPath} permissions: ${ide.permissions}`, reason);
|
|
127
|
+
checks.warnings += 1;
|
|
128
|
+
} else {
|
|
129
|
+
printPass(`${ide.displayPath} permissions: ${ide.permissions}`, 'restricted');
|
|
130
|
+
checks.passed += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check for lockfile presence
|
|
136
|
+
*/
|
|
137
|
+
function checkLockfile(checks) {
|
|
138
|
+
const lockfilePath = join(process.cwd(), LOCKFILE_NAME);
|
|
139
|
+
const homeLockfile = join(homedir(), LOCKFILE_NAME);
|
|
140
|
+
|
|
141
|
+
if (existsSync(lockfilePath) || existsSync(homeLockfile)) {
|
|
142
|
+
const path = existsSync(lockfilePath) ? lockfilePath : homeLockfile;
|
|
143
|
+
try {
|
|
144
|
+
const stats = statSync(path);
|
|
145
|
+
const ageMs = Date.now() - stats.mtimeMs;
|
|
146
|
+
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
|
|
147
|
+
|
|
148
|
+
if (ageDays > 30) {
|
|
149
|
+
printWarn(`${LOCKFILE_NAME} is ${ageDays} days old`, 'consider refreshing');
|
|
150
|
+
checks.warnings += 1;
|
|
151
|
+
} else {
|
|
152
|
+
printPass(`${LOCKFILE_NAME} found`, `${ageDays} days old`);
|
|
153
|
+
checks.passed += 1;
|
|
154
|
+
}
|
|
155
|
+
} catch (_err) {
|
|
156
|
+
printPass(`${LOCKFILE_NAME} found`, '');
|
|
157
|
+
checks.passed += 1;
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
printWarn(`No ${LOCKFILE_NAME} found`, 'run: npx mcp-shark lock');
|
|
161
|
+
checks.warnings += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check for duplicate tool names across servers
|
|
167
|
+
*/
|
|
168
|
+
function checkDuplicateToolNames(foundIdes, checks) {
|
|
169
|
+
const toolServers = new Map();
|
|
170
|
+
|
|
171
|
+
for (const ide of foundIdes) {
|
|
172
|
+
for (const [serverName, serverConfig] of Object.entries(ide.servers)) {
|
|
173
|
+
const tools = serverConfig.tools || [];
|
|
174
|
+
const toolNames = Array.isArray(tools)
|
|
175
|
+
? tools.map((t) => (typeof t === 'string' ? t : t.name))
|
|
176
|
+
: Object.keys(tools);
|
|
177
|
+
|
|
178
|
+
for (const toolName of toolNames) {
|
|
179
|
+
if (!toolServers.has(toolName)) {
|
|
180
|
+
toolServers.set(toolName, []);
|
|
181
|
+
}
|
|
182
|
+
toolServers.get(toolName).push(serverName);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const duplicates = [...toolServers.entries()].filter(([_name, servers]) => servers.length > 1);
|
|
188
|
+
|
|
189
|
+
if (duplicates.length > 0) {
|
|
190
|
+
for (const [name, servers] of duplicates) {
|
|
191
|
+
printWarn(`Duplicate tool "${name}"`, `in ${servers.join(', ')}`);
|
|
192
|
+
checks.warnings += 1;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
printPass('No duplicate tool names across servers', '');
|
|
196
|
+
checks.passed += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function printPass(label, detail) {
|
|
201
|
+
const detailText = detail ? kleur.dim(` ${detail}`) : '';
|
|
202
|
+
console.log(` ${kleur.green(S.pass)} ${label}${detailText}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function printWarn(label, detail) {
|
|
206
|
+
const detailText = detail ? kleur.dim(` (${detail})`) : '';
|
|
207
|
+
console.log(` ${kleur.yellow(S.warn)} ${label}${detailText}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function printFail(label, detail) {
|
|
211
|
+
const detailText = detail ? kleur.dim(` (${detail})`) : '';
|
|
212
|
+
console.log(` ${kleur.red(S.fail)} ${label}${detailText}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function printInfo(label, detail) {
|
|
216
|
+
const detailText = detail ? kleur.dim(` ${detail}`) : '';
|
|
217
|
+
console.log(` ${kleur.gray(S.info)} ${label}${detailText}`);
|
|
218
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix handler implementations
|
|
3
|
+
* Each handler applies a specific type of auto-fix with backup support
|
|
4
|
+
*/
|
|
5
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const BACKUP_SUFFIX = '.shark-backup';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Apply a single fix based on fix type
|
|
12
|
+
*/
|
|
13
|
+
export function applyFix(finding, envVarsCollected) {
|
|
14
|
+
if (finding.fix_type === 'env_var_replacement') {
|
|
15
|
+
return fixEnvVarReplacement(finding, envVarsCollected);
|
|
16
|
+
}
|
|
17
|
+
if (finding.fix_type === 'chmod') {
|
|
18
|
+
return fixPermissions(finding);
|
|
19
|
+
}
|
|
20
|
+
if (finding.fix_type === 'strip_ansi') {
|
|
21
|
+
return fixStripAnsi(finding);
|
|
22
|
+
}
|
|
23
|
+
return { success: false, finding, reason: 'Unknown fix type' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Replace hardcoded secret with environment variable reference
|
|
28
|
+
*/
|
|
29
|
+
function fixEnvVarReplacement(finding, envVarsCollected) {
|
|
30
|
+
const configPath = finding.config_path;
|
|
31
|
+
if (!configPath || !existsSync(configPath)) {
|
|
32
|
+
return { success: false, finding, error: 'Config file not found' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const envVarName = deriveEnvVarName(finding.fix_data.key);
|
|
37
|
+
backupFile(configPath);
|
|
38
|
+
|
|
39
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
40
|
+
const original = finding.fix_data.original;
|
|
41
|
+
const replacement = `\${${envVarName}}`;
|
|
42
|
+
const updated = content.replace(original, replacement);
|
|
43
|
+
|
|
44
|
+
if (updated === content) {
|
|
45
|
+
return { success: false, finding, reason: 'Value not found in config' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
writeFileSync(configPath, updated, 'utf-8');
|
|
49
|
+
envVarsCollected.push({ name: envVarName, original: maskValue(original) });
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
success: true,
|
|
53
|
+
finding,
|
|
54
|
+
message: `Replaced ${maskValue(original)} → \${${envVarName}}`,
|
|
55
|
+
backupPath: `${configPath}${BACKUP_SUFFIX}`,
|
|
56
|
+
};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { success: false, finding, error: err.message };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fix file permissions (chmod 600)
|
|
64
|
+
*/
|
|
65
|
+
function fixPermissions(finding) {
|
|
66
|
+
const configPath = finding.config_path;
|
|
67
|
+
if (!configPath || !existsSync(configPath)) {
|
|
68
|
+
return { success: false, finding, error: 'Config file not found' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.platform === 'win32') {
|
|
72
|
+
return { success: false, finding, reason: 'chmod not supported on Windows' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
backupFile(configPath);
|
|
77
|
+
const oldPerms = finding.fix_data?.oldPerms || '644';
|
|
78
|
+
chmodSync(configPath, 0o600);
|
|
79
|
+
return {
|
|
80
|
+
success: true,
|
|
81
|
+
finding,
|
|
82
|
+
message: `Set permissions: ${configPath} ${oldPerms} → 600`,
|
|
83
|
+
backupPath: `${configPath}${BACKUP_SUFFIX}`,
|
|
84
|
+
};
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { success: false, finding, error: err.message };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Strip ANSI escape sequences from tool description
|
|
92
|
+
*/
|
|
93
|
+
function fixStripAnsi(finding) {
|
|
94
|
+
const configPath = finding.config_path;
|
|
95
|
+
if (!configPath || !existsSync(configPath)) {
|
|
96
|
+
return { success: false, finding, error: 'Config file not found' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
backupFile(configPath);
|
|
101
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
102
|
+
const escChar = String.fromCharCode(0x1b);
|
|
103
|
+
const ansiPattern = new RegExp(`${escChar}\\[[0-9;]*m`, 'g');
|
|
104
|
+
const stripped = content.replace(ansiPattern, '');
|
|
105
|
+
|
|
106
|
+
if (stripped === content) {
|
|
107
|
+
return { success: false, finding, reason: 'No ANSI sequences found' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
writeFileSync(configPath, stripped, 'utf-8');
|
|
111
|
+
return {
|
|
112
|
+
success: true,
|
|
113
|
+
finding,
|
|
114
|
+
message: 'Stripped ANSI escape sequences from config',
|
|
115
|
+
backupPath: `${configPath}${BACKUP_SUFFIX}`,
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { success: false, finding, error: err.message };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create .env.example with required environment variable names
|
|
124
|
+
*/
|
|
125
|
+
export function createEnvExample(envVars, result) {
|
|
126
|
+
const envExamplePath = join(process.cwd(), '.env.example');
|
|
127
|
+
const existingContent = existsSync(envExamplePath) ? readFileSync(envExamplePath, 'utf-8') : '';
|
|
128
|
+
|
|
129
|
+
const newVars = envVars.filter((v) => !existingContent.includes(v.name));
|
|
130
|
+
|
|
131
|
+
if (newVars.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const lines = newVars.map((v) => `${v.name}=`);
|
|
136
|
+
const content = existingContent
|
|
137
|
+
? `${existingContent.trimEnd()}\n${lines.join('\n')}\n`
|
|
138
|
+
: `${lines.join('\n')}\n`;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(envExamplePath, content, 'utf-8');
|
|
142
|
+
result.fixed.push({
|
|
143
|
+
success: true,
|
|
144
|
+
finding: { title: 'Created .env.example' },
|
|
145
|
+
message: `Created .env.example with ${newVars.map((v) => v.name).join(', ')}`,
|
|
146
|
+
});
|
|
147
|
+
} catch (err) {
|
|
148
|
+
result.errors.push({
|
|
149
|
+
success: false,
|
|
150
|
+
finding: { title: 'Create .env.example' },
|
|
151
|
+
error: err.message,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Undo previous fixes by restoring backups
|
|
158
|
+
*/
|
|
159
|
+
export function undoFixes(findings) {
|
|
160
|
+
const result = { fixed: [], skipped: [], errors: [] };
|
|
161
|
+
|
|
162
|
+
const configPaths = new Set(findings.filter((f) => f.config_path).map((f) => f.config_path));
|
|
163
|
+
|
|
164
|
+
for (const configPath of configPaths) {
|
|
165
|
+
const backupPath = `${configPath}${BACKUP_SUFFIX}`;
|
|
166
|
+
if (!existsSync(backupPath)) {
|
|
167
|
+
result.skipped.push({
|
|
168
|
+
success: false,
|
|
169
|
+
reason: 'No backup found',
|
|
170
|
+
finding: { config_path: configPath },
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
copyFileSync(backupPath, configPath);
|
|
177
|
+
result.fixed.push({
|
|
178
|
+
success: true,
|
|
179
|
+
message: `Restored ${configPath} from backup`,
|
|
180
|
+
finding: { config_path: configPath },
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
result.errors.push({
|
|
184
|
+
success: false,
|
|
185
|
+
error: err.message,
|
|
186
|
+
finding: { config_path: configPath },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Create a backup of a file before modifying it
|
|
196
|
+
*/
|
|
197
|
+
function backupFile(filePath) {
|
|
198
|
+
const backupPath = `${filePath}${BACKUP_SUFFIX}`;
|
|
199
|
+
if (!existsSync(backupPath)) {
|
|
200
|
+
copyFileSync(filePath, backupPath);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Derive environment variable name from a config key
|
|
206
|
+
*/
|
|
207
|
+
function deriveEnvVarName(key) {
|
|
208
|
+
return key
|
|
209
|
+
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
|
210
|
+
.replace(/[^a-zA-Z0-9]/g, '_')
|
|
211
|
+
.toUpperCase();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Mask a secret value for display
|
|
216
|
+
*/
|
|
217
|
+
function maskValue(value) {
|
|
218
|
+
if (!value || value.length <= 8) {
|
|
219
|
+
return '****';
|
|
220
|
+
}
|
|
221
|
+
return `${value.slice(0, 4)}****`;
|
|
222
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Report Generator
|
|
3
|
+
* Produces a self-contained, offline HTML security report.
|
|
4
|
+
* No external CSS/JS dependencies — everything is inlined.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import kleur from 'kleur';
|
|
9
|
+
import { S } from './symbols.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate an HTML report from scan results
|
|
13
|
+
* @param {object} scanResult - Full scan result from ScanService
|
|
14
|
+
* @param {string} [outputPath] - Output file path (defaults to cwd/mcp-shark-report.html)
|
|
15
|
+
* @returns {string} Path to generated report
|
|
16
|
+
*/
|
|
17
|
+
export function generateHtmlReport(scanResult, outputPath) {
|
|
18
|
+
const reportPath = outputPath || join(process.cwd(), 'mcp-shark-report.html');
|
|
19
|
+
const html = buildHtml(scanResult);
|
|
20
|
+
writeFileSync(reportPath, html, 'utf-8');
|
|
21
|
+
console.log(` ${kleur.green(S.pass)} Report saved: ${reportPath}`);
|
|
22
|
+
return reportPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the complete HTML document
|
|
27
|
+
*/
|
|
28
|
+
function buildHtml(scanResult) {
|
|
29
|
+
const score = scanResult.scoreResult;
|
|
30
|
+
const timestamp = new Date().toISOString();
|
|
31
|
+
const gradeColor = getGradeColor(score.grade);
|
|
32
|
+
|
|
33
|
+
return `<!DOCTYPE html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="UTF-8">
|
|
37
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
38
|
+
<title>MCP Shark Security Report</title>
|
|
39
|
+
<style>
|
|
40
|
+
${getStyles()}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div class="container">
|
|
45
|
+
<header>
|
|
46
|
+
<h1>MCP Shark Security Report</h1>
|
|
47
|
+
<p class="timestamp">Generated: ${timestamp}</p>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<div class="score-card">
|
|
51
|
+
<div class="score-circle" style="border-color: ${gradeColor}">
|
|
52
|
+
<span class="score-number">${score.score}</span>
|
|
53
|
+
<span class="score-grade" style="color: ${gradeColor}">${score.grade}</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="score-details">
|
|
56
|
+
<h2>Shark Score</h2>
|
|
57
|
+
<p>${score.score}/100 — Grade ${score.grade}</p>
|
|
58
|
+
<p class="score-summary">${scanResult.findings.length} findings across ${scanResult.serverCount} servers</p>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="summary-bar">
|
|
63
|
+
${renderSummaryBadges(scanResult.severityCounts)}
|
|
64
|
+
<span class="badge badge-flow">${scanResult.toxicFlows.length} toxic flows</span>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<section class="findings">
|
|
68
|
+
<h2>Findings</h2>
|
|
69
|
+
${renderFindingsHtml(scanResult.findings)}
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
${renderToxicFlowsHtml(scanResult.toxicFlows)}
|
|
73
|
+
|
|
74
|
+
<section class="servers">
|
|
75
|
+
<h2>Servers Scanned</h2>
|
|
76
|
+
<table>
|
|
77
|
+
<thead><tr><th>Server</th><th>IDE</th><th>Findings</th></tr></thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
${renderServersTable(scanResult)}
|
|
80
|
+
</tbody>
|
|
81
|
+
</table>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<footer>
|
|
85
|
+
<p>mcp-shark v${getVersion()} — ${scanResult.ruleCount} rules · ${scanResult.elapsedMs}ms scan time · 100% local</p>
|
|
86
|
+
</footer>
|
|
87
|
+
</div>
|
|
88
|
+
</body>
|
|
89
|
+
</html>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getStyles() {
|
|
93
|
+
return `
|
|
94
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
95
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0d1117;color:#c9d1d9;line-height:1.6}
|
|
96
|
+
.container{max-width:900px;margin:0 auto;padding:2rem}
|
|
97
|
+
header{text-align:center;margin-bottom:2rem}
|
|
98
|
+
header h1{font-size:2rem;color:#58a6ff}
|
|
99
|
+
.timestamp{color:#8b949e;font-size:.85rem}
|
|
100
|
+
.score-card{display:flex;align-items:center;gap:2rem;background:#161b22;border:1px solid #30363d;border-radius:12px;padding:2rem;margin-bottom:1.5rem}
|
|
101
|
+
.score-circle{width:100px;height:100px;border-radius:50%;border:4px solid;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
|
102
|
+
.score-number{font-size:2rem;font-weight:bold;color:#fff}
|
|
103
|
+
.score-grade{font-size:1rem;font-weight:bold}
|
|
104
|
+
.score-details h2{color:#f0f6fc}
|
|
105
|
+
.score-summary{color:#8b949e}
|
|
106
|
+
.summary-bar{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:2rem}
|
|
107
|
+
.badge{padding:.25rem .75rem;border-radius:20px;font-size:.8rem;font-weight:600}
|
|
108
|
+
.badge-critical{background:#da3633;color:#fff}
|
|
109
|
+
.badge-high{background:#d29922;color:#fff}
|
|
110
|
+
.badge-medium{background:#1f6feb;color:#fff}
|
|
111
|
+
.badge-low{background:#30363d;color:#8b949e}
|
|
112
|
+
.badge-flow{background:#8957e5;color:#fff}
|
|
113
|
+
h2{color:#f0f6fc;margin:1.5rem 0 1rem;font-size:1.3rem}
|
|
114
|
+
.finding{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1rem;margin-bottom:.75rem}
|
|
115
|
+
.finding-header{display:flex;justify-content:space-between;align-items:center}
|
|
116
|
+
.finding-title{font-weight:600;color:#f0f6fc}
|
|
117
|
+
.sev{padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-transform:uppercase}
|
|
118
|
+
.sev-critical{background:#da3633;color:#fff}
|
|
119
|
+
.sev-high{background:#d29922;color:#fff}
|
|
120
|
+
.sev-medium{background:#1f6feb;color:#fff}
|
|
121
|
+
.sev-low{background:#30363d;color:#8b949e}
|
|
122
|
+
.finding-desc{color:#8b949e;margin-top:.5rem;font-size:.9rem}
|
|
123
|
+
.finding-meta{color:#484f58;font-size:.8rem;margin-top:.25rem}
|
|
124
|
+
.toxic-flow{background:#1c1229;border:1px solid #8957e5;border-radius:8px;padding:1rem;margin-bottom:.75rem}
|
|
125
|
+
.flow-chain{color:#d2a8ff;font-weight:600}
|
|
126
|
+
table{width:100%;border-collapse:collapse;background:#161b22;border-radius:8px;overflow:hidden}
|
|
127
|
+
th{background:#21262d;color:#f0f6fc;text-align:left;padding:.75rem 1rem;font-size:.85rem}
|
|
128
|
+
td{padding:.75rem 1rem;border-top:1px solid #30363d;font-size:.9rem}
|
|
129
|
+
footer{text-align:center;margin-top:3rem;color:#484f58;font-size:.8rem}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getGradeColor(grade) {
|
|
133
|
+
const colors = { A: '#3fb950', B: '#56d364', C: '#d29922', D: '#db6d28', F: '#da3633' };
|
|
134
|
+
return colors[grade] || '#8b949e';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderSummaryBadges(counts) {
|
|
138
|
+
return ['critical', 'high', 'medium', 'low']
|
|
139
|
+
.filter((s) => (counts[s] || 0) > 0)
|
|
140
|
+
.map((s) => `<span class="badge badge-${s}">${counts[s]} ${s}</span>`)
|
|
141
|
+
.join('\n ');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderFindingsHtml(findings) {
|
|
145
|
+
if (findings.length === 0) {
|
|
146
|
+
return '<p style="color:#3fb950">No security issues found.</p>';
|
|
147
|
+
}
|
|
148
|
+
return findings
|
|
149
|
+
.map((f) => {
|
|
150
|
+
const sev = (f.severity || 'medium').toLowerCase();
|
|
151
|
+
return `<div class="finding">
|
|
152
|
+
<div class="finding-header">
|
|
153
|
+
<span class="finding-title">${escapeHtml(f.title)}</span>
|
|
154
|
+
<span class="sev sev-${sev}">${sev}</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="finding-desc">${escapeHtml(f.description || '')}</div>
|
|
157
|
+
<div class="finding-meta">${f.rule_id || ''} · ${f.server_name || ''} · ${f.ide || ''}</div>
|
|
158
|
+
</div>`;
|
|
159
|
+
})
|
|
160
|
+
.join('\n ');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderToxicFlowsHtml(flows) {
|
|
164
|
+
if (flows.length === 0) {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
const items = flows
|
|
168
|
+
.map(
|
|
169
|
+
(f) => `<div class="toxic-flow">
|
|
170
|
+
<div class="flow-chain">${escapeHtml(f.chain || `${f.source} → ${f.sink}`)}</div>
|
|
171
|
+
<div class="finding-desc">${escapeHtml(f.scenario || f.description || '')}</div>
|
|
172
|
+
</div>`
|
|
173
|
+
)
|
|
174
|
+
.join('\n ');
|
|
175
|
+
return `<section><h2>Toxic Flows</h2>${items}</section>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderServersTable(scanResult) {
|
|
179
|
+
return scanResult.servers
|
|
180
|
+
.map((s) => {
|
|
181
|
+
const count = scanResult.findings.filter((f) => f.server_name === s.name).length;
|
|
182
|
+
return `<tr><td>${escapeHtml(s.name)}</td><td>${escapeHtml(s.ide)}</td><td>${count}</td></tr>`;
|
|
183
|
+
})
|
|
184
|
+
.join('\n ');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeHtml(str) {
|
|
188
|
+
return String(str)
|
|
189
|
+
.replace(/&/g, '&')
|
|
190
|
+
.replace(/</g, '<')
|
|
191
|
+
.replace(/>/g, '>')
|
|
192
|
+
.replace(/"/g, '"');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getVersion() {
|
|
196
|
+
try {
|
|
197
|
+
const pkgPath = join(import.meta.dirname, '..', '..', 'package.json');
|
|
198
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
199
|
+
return pkg.version;
|
|
200
|
+
} catch (_err) {
|
|
201
|
+
return '1.x';
|
|
202
|
+
}
|
|
203
|
+
}
|