@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.
Files changed (158) hide show
  1. package/README.md +482 -56
  2. package/bin/mcp-shark.js +146 -52
  3. package/core/cli/AutoFixEngine.js +93 -0
  4. package/core/cli/ConfigScanner.js +193 -0
  5. package/core/cli/DataLoader.js +200 -0
  6. package/core/cli/DeclarativeRuleEngine.js +363 -0
  7. package/core/cli/DoctorCommand.js +218 -0
  8. package/core/cli/FixHandlers.js +222 -0
  9. package/core/cli/HtmlReportGenerator.js +203 -0
  10. package/core/cli/IdeConfigPaths.js +175 -0
  11. package/core/cli/ListCommand.js +255 -0
  12. package/core/cli/LockCommand.js +164 -0
  13. package/core/cli/LockDiffEngine.js +152 -0
  14. package/core/cli/RuleRegistryConfig.js +131 -0
  15. package/core/cli/ScanCommand.js +244 -0
  16. package/core/cli/ScanService.js +200 -0
  17. package/core/cli/SecretDetector.js +92 -0
  18. package/core/cli/SharkScoreCalculator.js +109 -0
  19. package/core/cli/ToolClassifications.js +51 -0
  20. package/core/cli/ToxicFlowAnalyzer.js +212 -0
  21. package/core/cli/UpdateCommand.js +188 -0
  22. package/core/cli/WalkthroughGenerator.js +195 -0
  23. package/core/cli/WatchCommand.js +129 -0
  24. package/core/cli/YamlRuleEngine.js +197 -0
  25. package/core/cli/data/rule-packs/aauth-visibility.json +117 -0
  26. package/core/cli/data/rule-packs/agentic-security-2026.json +180 -0
  27. package/core/cli/data/rule-packs/general-security.json +173 -0
  28. package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
  29. package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
  30. package/core/cli/data/rule-sources.json +5 -0
  31. package/core/cli/data/secret-patterns.json +18 -0
  32. package/core/cli/data/tool-classifications.json +111 -0
  33. package/core/cli/data/toxic-flow-rules.json +47 -0
  34. package/core/cli/index.js +23 -0
  35. package/core/cli/output/Banner.js +52 -0
  36. package/core/cli/output/Formatter.js +183 -0
  37. package/core/cli/output/JsonFormatter.js +106 -0
  38. package/core/cli/output/index.js +16 -0
  39. package/core/cli/secureRegistryFetch.js +157 -0
  40. package/core/cli/symbols.js +16 -0
  41. package/core/configs/environment.js +3 -1
  42. package/core/configs/index.js +3 -64
  43. package/core/container/DependencyContainer.js +4 -1
  44. package/core/mcp-server/index.js +4 -1
  45. package/core/mcp-server/server/external/all.js +10 -3
  46. package/core/mcp-server/server/external/config.js +62 -5
  47. package/core/models/RequestFilters.js +3 -0
  48. package/core/repositories/PacketRepository.js +16 -0
  49. package/core/services/AuditService.js +2 -0
  50. package/core/services/ConfigService.js +9 -1
  51. package/core/services/ConfigTransformService.js +34 -2
  52. package/core/services/RequestService.js +58 -5
  53. package/core/services/ServerManagementService.js +59 -4
  54. package/core/services/security/StaticRulesService.js +69 -13
  55. package/core/services/security/TrafficAnalysisService.js +19 -1
  56. package/core/services/security/TrafficToxicFlowService.js +154 -0
  57. package/core/services/security/aauthGraph.js +199 -0
  58. package/core/services/security/aauthParser.js +274 -0
  59. package/core/services/security/aauthSelfTest.js +346 -0
  60. package/core/services/security/index.js +2 -1
  61. package/core/services/security/rules/index.js +25 -59
  62. package/core/services/security/rules/scans/configPermissions.js +91 -0
  63. package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
  64. package/core/services/security/rules/scans/insecureTransport.js +148 -0
  65. package/core/services/security/rules/scans/missingContainment.js +123 -0
  66. package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
  67. package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
  68. package/core/services/security/toolsListFromTrafficParser.js +70 -0
  69. package/core/tui/App.js +144 -0
  70. package/core/tui/FindingsPanel.js +115 -0
  71. package/core/tui/FixPanel.js +132 -0
  72. package/core/tui/Header.js +51 -0
  73. package/core/tui/HelpBar.js +42 -0
  74. package/core/tui/ServersPanel.js +109 -0
  75. package/core/tui/ToxicFlowsPanel.js +100 -0
  76. package/core/tui/h.js +8 -0
  77. package/core/tui/index.js +11 -0
  78. package/core/tui/render.js +22 -0
  79. package/package.json +24 -16
  80. package/ui/dist/assets/index-D6zDrtMV.js +81 -0
  81. package/ui/dist/index.html +1 -1
  82. package/ui/server/controllers/AauthController.js +279 -0
  83. package/ui/server/controllers/RequestController.js +12 -1
  84. package/ui/server/controllers/SecurityFindingsController.js +46 -1
  85. package/ui/server/routes/aauth.js +18 -0
  86. package/ui/server/routes/requests.js +8 -1
  87. package/ui/server/routes/security.js +5 -1
  88. package/ui/server/setup.js +224 -6
  89. package/ui/server/swagger/paths/components.js +55 -0
  90. package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
  91. package/ui/server/swagger/paths.js +2 -2
  92. package/ui/server/swagger/swagger.js +5 -2
  93. package/ui/server.js +1 -1
  94. package/ui/src/App.jsx +26 -52
  95. package/ui/src/PacketFilters.jsx +31 -1
  96. package/ui/src/PacketList.jsx +2 -2
  97. package/ui/src/Security.jsx +10 -0
  98. package/ui/src/TabNavigation.jsx +8 -0
  99. package/ui/src/components/AAuthBadge.jsx +92 -0
  100. package/ui/src/components/AauthExplorer/AauthExplorerGraph.jsx +231 -0
  101. package/ui/src/components/AauthExplorer/AauthExplorerView.jsx +387 -0
  102. package/ui/src/components/AauthExplorer/NodeDetailPanel.jsx +272 -0
  103. package/ui/src/components/App/ActionMenu.jsx +4 -31
  104. package/ui/src/components/App/ApiDocsButton.jsx +0 -1
  105. package/ui/src/components/App/ShutdownButton.jsx +0 -1
  106. package/ui/src/components/App/useAppState.js +19 -26
  107. package/ui/src/components/DetailsTab/AAuthIdentitySection.jsx +119 -0
  108. package/ui/src/components/DetailsTab/RequestDetailsSection.jsx +2 -0
  109. package/ui/src/components/DetailsTab/ResponseDetailsSection.jsx +2 -0
  110. package/ui/src/components/DetectedPathsList.jsx +1 -5
  111. package/ui/src/components/FileInput.jsx +0 -1
  112. package/ui/src/components/PacketFilters/AAuthPostureFilter.jsx +81 -0
  113. package/ui/src/components/RequestRow/RequestRowMain.jsx +7 -1
  114. package/ui/src/components/Security/AAuthPosturePanel.jsx +360 -0
  115. package/ui/src/components/Security/ScannerContent.jsx +33 -1
  116. package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
  117. package/ui/src/components/Security/securityApi.js +15 -0
  118. package/ui/src/components/Security/useSecurity.js +60 -3
  119. package/ui/src/components/ServerControl.jsx +0 -1
  120. package/ui/src/components/TabNavigation/DesktopTabs.jsx +0 -11
  121. package/ui/src/components/TabNavigationIcons.jsx +5 -0
  122. package/ui/src/components/ViewModeTabs.jsx +0 -1
  123. package/ui/src/utils/animations.js +26 -9
  124. package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
  125. package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
  126. package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
  127. package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
  128. package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
  129. package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
  130. package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
  131. package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
  132. package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
  133. package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
  134. package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
  135. package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
  136. package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
  137. package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
  138. package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
  139. package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
  140. package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
  141. package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
  142. package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
  143. package/ui/dist/assets/index-CiCSDYf-.js +0 -97
  144. package/ui/server/routes/help.js +0 -44
  145. package/ui/server/swagger/paths/help.js +0 -82
  146. package/ui/src/HelpGuide/HelpGuideContent.jsx +0 -118
  147. package/ui/src/HelpGuide/HelpGuideFooter.jsx +0 -59
  148. package/ui/src/HelpGuide/HelpGuideHeader.jsx +0 -57
  149. package/ui/src/HelpGuide.jsx +0 -78
  150. package/ui/src/IntroTour.jsx +0 -154
  151. package/ui/src/components/App/HelpButton.jsx +0 -90
  152. package/ui/src/components/TourOverlay.jsx +0 -117
  153. package/ui/src/components/TourTooltip/TourTooltipButtons.jsx +0 -120
  154. package/ui/src/components/TourTooltip/TourTooltipHeader.jsx +0 -71
  155. package/ui/src/components/TourTooltip/TourTooltipIcons.jsx +0 -54
  156. package/ui/src/components/TourTooltip/useTooltipPosition.js +0 -135
  157. package/ui/src/components/TourTooltip.jsx +0 -91
  158. 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, '&amp;')
190
+ .replace(/</g, '&lt;')
191
+ .replace(/>/g, '&gt;')
192
+ .replace(/"/g, '&quot;');
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
+ }