@mcp-shark/mcp-shark 1.5.13 → 1.6.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.
Files changed (100) hide show
  1. package/README.md +396 -59
  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 +224 -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/agentic-security-2026.json +180 -0
  26. package/core/cli/data/rule-packs/general-security.json +173 -0
  27. package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
  28. package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
  29. package/core/cli/data/rule-sources.json +5 -0
  30. package/core/cli/data/secret-patterns.json +18 -0
  31. package/core/cli/data/tool-classifications.json +111 -0
  32. package/core/cli/data/toxic-flow-rules.json +47 -0
  33. package/core/cli/index.js +23 -0
  34. package/core/cli/output/Banner.js +52 -0
  35. package/core/cli/output/Formatter.js +183 -0
  36. package/core/cli/output/JsonFormatter.js +106 -0
  37. package/core/cli/output/index.js +16 -0
  38. package/core/cli/secureRegistryFetch.js +157 -0
  39. package/core/cli/symbols.js +16 -0
  40. package/core/container/DependencyContainer.js +4 -1
  41. package/core/repositories/PacketRepository.js +16 -0
  42. package/core/services/AuditService.js +2 -0
  43. package/core/services/security/StaticRulesService.js +69 -13
  44. package/core/services/security/TrafficAnalysisService.js +19 -1
  45. package/core/services/security/TrafficToxicFlowService.js +154 -0
  46. package/core/services/security/index.js +2 -1
  47. package/core/services/security/rules/index.js +25 -59
  48. package/core/services/security/rules/scans/configPermissions.js +91 -0
  49. package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
  50. package/core/services/security/rules/scans/insecureTransport.js +148 -0
  51. package/core/services/security/rules/scans/missingContainment.js +123 -0
  52. package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
  53. package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
  54. package/core/services/security/toolsListFromTrafficParser.js +70 -0
  55. package/core/tui/App.js +144 -0
  56. package/core/tui/FindingsPanel.js +115 -0
  57. package/core/tui/FixPanel.js +132 -0
  58. package/core/tui/Header.js +51 -0
  59. package/core/tui/HelpBar.js +42 -0
  60. package/core/tui/ServersPanel.js +109 -0
  61. package/core/tui/ToxicFlowsPanel.js +100 -0
  62. package/core/tui/h.js +8 -0
  63. package/core/tui/index.js +11 -0
  64. package/core/tui/render.js +22 -0
  65. package/package.json +24 -16
  66. package/ui/dist/assets/{index-CiCSDYf-.js → index-Buah4fNS.js} +32 -32
  67. package/ui/dist/index.html +1 -1
  68. package/ui/server/controllers/RequestController.js +9 -1
  69. package/ui/server/controllers/SecurityFindingsController.js +46 -1
  70. package/ui/server/routes/requests.js +8 -1
  71. package/ui/server/routes/security.js +5 -1
  72. package/ui/server/setup.js +2 -0
  73. package/ui/server/swagger/paths/components.js +55 -0
  74. package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
  75. package/ui/server/swagger/paths.js +2 -0
  76. package/ui/server/swagger/swagger.js +5 -1
  77. package/ui/src/Security.jsx +10 -0
  78. package/ui/src/components/Security/ScannerContent.jsx +30 -1
  79. package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
  80. package/ui/src/components/Security/securityApi.js +15 -0
  81. package/ui/src/components/Security/useSecurity.js +60 -3
  82. package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
  83. package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
  84. package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
  85. package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
  86. package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
  87. package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
  88. package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
  89. package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
  90. package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
  91. package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
  92. package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
  93. package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
  94. package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
  95. package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
  96. package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
  97. package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
  98. package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
  99. package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
  100. package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Resolves rule registry URL and update policy without hardcoding beyond bootstrap defaults.
3
+ *
4
+ * Precedence (highest first):
5
+ * 1. CLI --source (passed in as overrideUrl)
6
+ * 2. MCP_SHARK_RULE_REGISTRY
7
+ * 3. .mcp-shark/rule-registry.json (project cwd)
8
+ * 4. ~/.config/mcp-shark/rule-registry.json (XDG-style)
9
+ * 5. Built-in rule-sources.json
10
+ */
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { loadBuiltinJson } from './DataLoader.js';
15
+
16
+ const BUILTIN = loadBuiltinJson('rule-sources.json');
17
+
18
+ function projectRegistryPath() {
19
+ return join(process.cwd(), '.mcp-shark', 'rule-registry.json');
20
+ }
21
+
22
+ function userRegistryPath() {
23
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
24
+ return join(base, 'mcp-shark', 'rule-registry.json');
25
+ }
26
+
27
+ /**
28
+ * @param {object} raw
29
+ * @returns {object|null}
30
+ */
31
+ function parseRegistryFile(rawPath) {
32
+ if (!existsSync(rawPath)) {
33
+ return null;
34
+ }
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(rawPath, 'utf-8'));
37
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Reject cache_dir path traversal and absolute paths.
45
+ * @param {string} rel
46
+ */
47
+ function assertSafeRelativeCacheDir(rel) {
48
+ if (typeof rel !== 'string' || rel.length === 0) {
49
+ return;
50
+ }
51
+ if (rel.includes('..') || rel.startsWith('/') || /^[A-Za-z]:[\\/]/.test(rel)) {
52
+ throw new Error('rule-registry.json cache_dir must be a relative path without ..');
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {object} [opts]
58
+ * @param {string} [opts.overrideUrl] - from CLI --source
59
+ * @returns {{
60
+ * registryUrl: string,
61
+ * cacheDir: string,
62
+ * autoUpdate: boolean,
63
+ * autoUpdateMaxAgeHours: number
64
+ * }}
65
+ */
66
+ export function resolveRuleRegistryConfig(opts = {}) {
67
+ const projectFile = parseRegistryFile(projectRegistryPath());
68
+ const userFile = parseRegistryFile(userRegistryPath());
69
+
70
+ const merged = {
71
+ ...BUILTIN,
72
+ ...(userFile || {}),
73
+ ...(projectFile || {}),
74
+ };
75
+
76
+ if (merged.cache_dir) {
77
+ assertSafeRelativeCacheDir(merged.cache_dir);
78
+ }
79
+
80
+ const cliUrl = opts.overrideUrl && String(opts.overrideUrl).trim();
81
+ const envUrl = process.env.MCP_SHARK_RULE_REGISTRY?.trim();
82
+ const registryUrl = cliUrl || envUrl || merged.registry_url || BUILTIN.registry_url;
83
+
84
+ const cacheDirRel = merged.cache_dir || BUILTIN.cache_dir;
85
+ assertSafeRelativeCacheDir(cacheDirRel);
86
+ const cacheDir = join(process.cwd(), cacheDirRel);
87
+
88
+ const autoUpdate = merged.auto_update === true;
89
+ const autoUpdateMaxAgeHours = Number(merged.auto_update_max_age_hours);
90
+ const builtinMax = Number(BUILTIN.default_auto_update_max_age_hours);
91
+ const maxAge =
92
+ Number.isFinite(autoUpdateMaxAgeHours) && autoUpdateMaxAgeHours > 0
93
+ ? autoUpdateMaxAgeHours
94
+ : Number.isFinite(builtinMax) && builtinMax > 0
95
+ ? builtinMax
96
+ : 168;
97
+
98
+ return {
99
+ registryUrl,
100
+ cacheDir,
101
+ autoUpdate,
102
+ autoUpdateMaxAgeHours: maxAge,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * True when cache has no packs or newest pack file is older than maxAgeHours.
108
+ * @param {string} cacheDir
109
+ * @param {number} maxAgeHours
110
+ */
111
+ export function isRuleCacheStale(cacheDir, maxAgeHours) {
112
+ if (!existsSync(cacheDir)) {
113
+ return true;
114
+ }
115
+
116
+ const files = readdirSync(cacheDir).filter((f) => f.endsWith('.json') && f !== '.meta.json');
117
+ if (files.length === 0) {
118
+ return true;
119
+ }
120
+
121
+ let newest = 0;
122
+ for (const f of files) {
123
+ const m = statSync(join(cacheDir, f)).mtimeMs;
124
+ if (m > newest) {
125
+ newest = m;
126
+ }
127
+ }
128
+
129
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
130
+ return Date.now() - newest > maxAgeMs;
131
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Scan Command
3
+ * Wires ScanService results to CLI output with flag support
4
+ */
5
+ import { confirm } from '@clack/prompts';
6
+ import { applyFixes, renderFixResults } from './AutoFixEngine.js';
7
+ import { generateHtmlReport } from './HtmlReportGenerator.js';
8
+ import { isRuleCacheStale, resolveRuleRegistryConfig } from './RuleRegistryConfig.js';
9
+ import { runScan } from './ScanService.js';
10
+ import { calculateSharkScore } from './SharkScoreCalculator.js';
11
+ import { executeUpdateRules } from './UpdateCommand.js';
12
+ import { formatWalkthrough, generateWalkthroughs } from './WalkthroughGenerator.js';
13
+ import {
14
+ displayScanBanner,
15
+ formatAsJson,
16
+ formatAsSarif,
17
+ formatCleanServers,
18
+ formatIdeDiscovery,
19
+ formatNextSteps,
20
+ formatServerFindings,
21
+ formatSharkScore,
22
+ formatSummaryCounts,
23
+ formatTiming,
24
+ formatToxicFlows,
25
+ } from './output/index.js';
26
+
27
+ /**
28
+ * Execute the scan command
29
+ * @param {object} options - CLI options from commander
30
+ * @param {boolean} [options.fix] - Auto-fix fixable issues
31
+ * @param {boolean} [options.walkthrough] - Show attack chain narratives
32
+ * @param {boolean} [options.ci] - CI mode (exit code 1 on critical/high)
33
+ * @param {string} [options.format] - Output format: 'json' | 'sarif' | 'terminal'
34
+ * @param {boolean} [options.strict] - Count advisory findings in score
35
+ * @param {string} [options.ide] - Filter to specific IDE
36
+ * @param {boolean} [options.yes] - Skip confirmation for --fix
37
+ * @param {string} [options.output] - Output file path (for html format)
38
+ * @param {string} [options.rules] - Path to custom YAML rules directory
39
+ * @param {boolean} [options.refreshRules] - Fetch registry packs before scan
40
+ */
41
+ export async function executeScan(options = {}) {
42
+ const refreshExit = await maybeRefreshRulesBeforeScan(options);
43
+ if (refreshExit !== 0) {
44
+ return refreshExit;
45
+ }
46
+
47
+ const format = (options.format || 'terminal').toLowerCase();
48
+
49
+ const scanResult = runScan({
50
+ ide: options.ide,
51
+ strict: options.strict,
52
+ rulesPath: options.rules,
53
+ });
54
+
55
+ if (format === 'json') {
56
+ console.log(formatAsJson(buildJsonOutput(scanResult)));
57
+ return exitWithCode(scanResult, options.ci);
58
+ }
59
+
60
+ if (format === 'sarif') {
61
+ console.log(formatAsSarif(buildJsonOutput(scanResult)));
62
+ return exitWithCode(scanResult, options.ci);
63
+ }
64
+
65
+ if (format === 'html') {
66
+ generateHtmlReport(scanResult, options.output);
67
+ return exitWithCode(scanResult, options.ci);
68
+ }
69
+
70
+ renderTerminalOutput(scanResult, options);
71
+
72
+ if (options.fix) {
73
+ await executeAutoFix(scanResult, {
74
+ undo: options.undo,
75
+ skipConfirm: options.yes || options.ci,
76
+ });
77
+ }
78
+
79
+ return exitWithCode(scanResult, options.ci);
80
+ }
81
+
82
+ /**
83
+ * Optional network: only when --refresh-rules or auto_update + stale cache.
84
+ * --refresh-rules: failure exits non-zero. Background auto-update: fail-open (scan continues).
85
+ */
86
+ async function maybeRefreshRulesBeforeScan(options) {
87
+ const config = resolveRuleRegistryConfig({});
88
+ if (options.refreshRules) {
89
+ return executeUpdateRules({ quiet: true });
90
+ }
91
+ if (config.autoUpdate && isRuleCacheStale(config.cacheDir, config.autoUpdateMaxAgeHours)) {
92
+ await executeUpdateRules({ quiet: true });
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ /**
98
+ * Render the full terminal output
99
+ */
100
+ function renderTerminalOutput(scanResult, options) {
101
+ displayScanBanner();
102
+ console.log(formatIdeDiscovery(scanResult.ideResults));
103
+ console.log('');
104
+
105
+ renderFindings(scanResult);
106
+ renderToxicFlows(scanResult);
107
+ renderScore(scanResult);
108
+
109
+ if (options.walkthrough && scanResult.toxicFlows.length > 0) {
110
+ renderWalkthroughs(scanResult.toxicFlows);
111
+ }
112
+
113
+ const hasFixable = scanResult.findings.some((f) => f.fixable);
114
+ const hasFlows = scanResult.toxicFlows.length > 0;
115
+ console.log(formatNextSteps(hasFixable, hasFlows));
116
+ console.log('');
117
+ }
118
+
119
+ /**
120
+ * Render findings grouped by server
121
+ */
122
+ function renderFindings(scanResult) {
123
+ for (const [serverName, findings] of Object.entries(scanResult.findingsByServer)) {
124
+ const server = scanResult.servers.find((s) => s.name === serverName);
125
+ const ideName = server ? server.ide : 'unknown';
126
+ console.log(formatServerFindings(serverName, ideName, findings));
127
+ }
128
+
129
+ if (scanResult.cleanServers.length > 0) {
130
+ console.log(formatCleanServers(scanResult.cleanServers));
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Render toxic flows section
136
+ */
137
+ function renderToxicFlows(scanResult) {
138
+ if (scanResult.toxicFlows.length > 0) {
139
+ console.log(formatToxicFlows(scanResult.toxicFlows));
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Render score and summary
145
+ */
146
+ function renderScore(scanResult) {
147
+ console.log('');
148
+ console.log(formatSharkScore(scanResult.scoreResult));
149
+ console.log(formatSummaryCounts(scanResult.severityCounts, scanResult.toxicFlows.length));
150
+ console.log(
151
+ formatTiming(
152
+ scanResult.elapsedMs,
153
+ scanResult.serverCount,
154
+ scanResult.ruleCount,
155
+ scanResult.totalToolCount
156
+ )
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Render attack walkthroughs
162
+ */
163
+ function renderWalkthroughs(toxicFlows) {
164
+ const walkthroughs = generateWalkthroughs(toxicFlows);
165
+ for (const walkthrough of walkthroughs) {
166
+ console.log(formatWalkthrough(walkthrough));
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Execute auto-fix (or undo) with optional interactive confirmation
172
+ */
173
+ async function executeAutoFix(scanResult, fixOptions = {}) {
174
+ const fixable = scanResult.findings.filter((f) => f.fixable);
175
+
176
+ if (fixable.length === 0 && !fixOptions.undo) {
177
+ return;
178
+ }
179
+
180
+ if (!fixOptions.skipConfirm && !fixOptions.undo) {
181
+ const shouldProceed = await confirm({
182
+ message: `Apply ${fixable.length} auto-fixes? (backups will be created)`,
183
+ });
184
+ if (!shouldProceed || typeof shouldProceed === 'symbol') {
185
+ console.log(' Fix cancelled.');
186
+ return;
187
+ }
188
+ }
189
+
190
+ const scoreBefore = scanResult.scoreResult.score;
191
+ const fixResult = applyFixes(scanResult.findings, { undo: fixOptions.undo });
192
+
193
+ const remainingFindings = scanResult.findings.filter(
194
+ (f) => !fixResult.fixed.some((fx) => fx.finding === f)
195
+ );
196
+ const scoreAfterResult = calculateSharkScore(remainingFindings, scanResult.toxicFlows);
197
+ const scoreAfter = scoreAfterResult.score;
198
+
199
+ renderFixResults(fixResult, scoreBefore, scoreAfter);
200
+ }
201
+
202
+ /**
203
+ * Build structured JSON output
204
+ */
205
+ function buildJsonOutput(scanResult) {
206
+ return {
207
+ version: '1.0.0',
208
+ timestamp: new Date().toISOString(),
209
+ score: scanResult.scoreResult,
210
+ findings: scanResult.findings,
211
+ toxicFlows: scanResult.toxicFlows,
212
+ servers: scanResult.servers.map((s) => ({
213
+ name: s.name,
214
+ ide: s.ide,
215
+ configPath: s.configPath,
216
+ })),
217
+ summary: {
218
+ serverCount: scanResult.serverCount,
219
+ ruleCount: scanResult.ruleCount,
220
+ toolCount: scanResult.totalToolCount,
221
+ elapsedMs: scanResult.elapsedMs,
222
+ severityCounts: scanResult.severityCounts,
223
+ },
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Exit with appropriate code for CI mode
229
+ */
230
+ function exitWithCode(scanResult, ciMode) {
231
+ if (!ciMode) {
232
+ return 0;
233
+ }
234
+
235
+ const hasCriticalOrHigh = scanResult.findings.some((f) => {
236
+ const severity = (f.severity || f.risk_level || '').toLowerCase();
237
+ return severity === 'critical' || severity === 'high';
238
+ });
239
+
240
+ if (hasCriticalOrHigh) {
241
+ return 1;
242
+ }
243
+ return 0;
244
+ }
@@ -0,0 +1,200 @@
1
+ import { StaticRulesService } from '#core/services/security/StaticRulesService.js';
2
+ /**
3
+ * Scan Service
4
+ * Orchestrates: config detection → rule analysis → toxic flows → shark score
5
+ * Pure business logic, no HTTP knowledge, no CLI formatting.
6
+ *
7
+ * Server-level checks are registry-driven — add a new check by
8
+ * appending to SERVER_CONFIG_CHECKS, no other edits needed.
9
+ */
10
+ import { analyzeConfigPermissions } from '#core/services/security/rules/scans/configPermissions.js';
11
+ import { analyzeAllServerToolNames } from '#core/services/security/rules/scans/duplicateToolNames.js';
12
+ import { analyzeServerTransport } from '#core/services/security/rules/scans/insecureTransport.js';
13
+ import { analyzeServerContainment } from '#core/services/security/rules/scans/missingContainment.js';
14
+ import { analyzeServerShellRisk } from '#core/services/security/rules/scans/shellEnvInjection.js';
15
+ import { analyzeServerDefaults } from '#core/services/security/rules/scans/unsafeDefaults.js';
16
+ import { getAllServers, scanIdeConfigs } from './ConfigScanner.js';
17
+ import { detectHardcodedSecrets } from './SecretDetector.js';
18
+ import { calculateSharkScore, countBySeverity } from './SharkScoreCalculator.js';
19
+ import { analyzeToxicFlows } from './ToxicFlowAnalyzer.js';
20
+ import { applyYamlRules, loadYamlRules } from './YamlRuleEngine.js';
21
+
22
+ /**
23
+ * Registry of server-level config checks.
24
+ * Each entry takes (serverName, config) and returns Finding[].
25
+ * To add a new check: import it and append here.
26
+ */
27
+ const SERVER_CONFIG_CHECKS = [
28
+ analyzeServerContainment,
29
+ analyzeServerShellRisk,
30
+ analyzeServerTransport,
31
+ analyzeServerDefaults,
32
+ ];
33
+
34
+ /**
35
+ * Run a full security scan on all detected MCP configurations
36
+ * @param {object} options
37
+ * @param {string} [options.ide] - Filter to specific IDE
38
+ * @param {boolean} [options.strict] - Count advisory findings in score
39
+ * @param {string} [options.rulesPath] - Path to custom YAML rules
40
+ * @returns {object} Complete scan result
41
+ */
42
+ export function runScan(options = {}) {
43
+ const startTime = Date.now();
44
+
45
+ const ideResults = scanIdeConfigs({ ide: options.ide });
46
+ const servers = getAllServers(ideResults);
47
+
48
+ const rulesService = new StaticRulesService(null);
49
+ const ruleMetadata = rulesService.getRuleMetadata();
50
+
51
+ const allFindings = analyzeAllServers(servers, rulesService, ideResults);
52
+
53
+ const yamlRules = loadYamlRules(options.rulesPath);
54
+ if (yamlRules.length > 0) {
55
+ const yamlFindings = applyYamlRules(yamlRules, servers);
56
+ allFindings.push(...yamlFindings);
57
+ }
58
+
59
+ const toxicFlows = analyzeToxicFlows(servers);
60
+
61
+ const scorableFindings = options.strict
62
+ ? allFindings
63
+ : allFindings.filter((f) => f.confidence !== 'possible');
64
+
65
+ const scoreResult = calculateSharkScore(scorableFindings, toxicFlows);
66
+ const severityCounts = countBySeverity(allFindings);
67
+ const elapsedMs = Date.now() - startTime;
68
+
69
+ const totalToolCount = servers.reduce(
70
+ (sum, s) => sum + (Array.isArray(s.tools) ? s.tools.length : 0),
71
+ 0
72
+ );
73
+
74
+ return {
75
+ ideResults,
76
+ servers,
77
+ findings: allFindings,
78
+ findingsByServer: groupFindingsByServer(allFindings),
79
+ toxicFlows,
80
+ scoreResult,
81
+ severityCounts,
82
+ ruleCount: ruleMetadata.length,
83
+ totalToolCount,
84
+ serverCount: servers.length,
85
+ elapsedMs,
86
+ cleanServers: getCleanServers(servers, allFindings),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Analyze all servers with the static rules engine + config-level checks
92
+ */
93
+ function analyzeAllServers(servers, rulesService, ideResults) {
94
+ const findings = [];
95
+
96
+ for (const server of servers) {
97
+ const serverFindings = analyzeServer(server, rulesService);
98
+ findings.push(...serverFindings);
99
+ }
100
+
101
+ const dupFindings = analyzeAllServerToolNames(servers);
102
+ findings.push(...dupFindings);
103
+
104
+ const permFindings = analyzeIdePermissions(ideResults);
105
+ findings.push(...permFindings);
106
+
107
+ return findings;
108
+ }
109
+
110
+ /**
111
+ * Check permissions on all found IDE config files
112
+ */
113
+ function analyzeIdePermissions(ideResults) {
114
+ const findings = [];
115
+ for (const ide of ideResults) {
116
+ if (!ide.found || !ide.permissions) {
117
+ continue;
118
+ }
119
+ const permFindings = analyzeConfigPermissions(ide.configPath, ide.permissions);
120
+ findings.push(...permFindings);
121
+ }
122
+ return findings;
123
+ }
124
+
125
+ /**
126
+ * Analyze a single server's tools with the rules engine
127
+ */
128
+ function analyzeServer(server, rulesService) {
129
+ const findings = [];
130
+
131
+ const configFindings = runServerConfigChecks(server);
132
+ findings.push(...configFindings);
133
+
134
+ if (Array.isArray(server.tools)) {
135
+ for (const tool of server.tools) {
136
+ const toolObj = typeof tool === 'string' ? { name: tool } : tool;
137
+ const toolFindings = rulesService.analyzeTool(toolObj, server.name);
138
+ for (const f of toolFindings) {
139
+ findings.push({
140
+ ...f,
141
+ ide: server.ide,
142
+ config_path: server.configPath,
143
+ });
144
+ }
145
+ }
146
+ }
147
+
148
+ const serverConfig = server.config || {};
149
+ if (serverConfig.env) {
150
+ const secretFindings = detectHardcodedSecrets(serverConfig.env, server);
151
+ findings.push(...secretFindings);
152
+ }
153
+
154
+ return findings;
155
+ }
156
+
157
+ /**
158
+ * Run all registered server-level config checks against a single server.
159
+ * Iterates SERVER_CONFIG_CHECKS registry — no manual calls needed.
160
+ */
161
+ function runServerConfigChecks(server) {
162
+ const config = server.config || {};
163
+ const findings = [];
164
+
165
+ for (const check of SERVER_CONFIG_CHECKS) {
166
+ const checkFindings = check(server.name, config);
167
+ for (const f of checkFindings) {
168
+ findings.push({
169
+ ...f,
170
+ ide: server.ide,
171
+ config_path: server.configPath,
172
+ });
173
+ }
174
+ }
175
+
176
+ return findings;
177
+ }
178
+
179
+ /**
180
+ * Group findings by server name
181
+ */
182
+ function groupFindingsByServer(findings) {
183
+ const grouped = {};
184
+ for (const finding of findings) {
185
+ const key = finding.server_name || 'unknown';
186
+ if (!grouped[key]) {
187
+ grouped[key] = [];
188
+ }
189
+ grouped[key].push(finding);
190
+ }
191
+ return grouped;
192
+ }
193
+
194
+ /**
195
+ * Get list of servers with no findings
196
+ */
197
+ function getCleanServers(servers, findings) {
198
+ const serversWithFindings = new Set(findings.map((f) => f.server_name));
199
+ return servers.filter((s) => !serversWithFindings.has(s.name)).map((s) => s.name);
200
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Hardcoded Secret Detection
3
+ * Detects API keys, tokens, and credentials hardcoded in MCP server env vars.
4
+ *
5
+ * Patterns are loaded from data/secret-patterns.json (built-in)
6
+ * and merged with user overrides from .mcp-shark/secrets.yaml.
7
+ */
8
+ import { loadBuiltinJson, loadUserYamlList } from './DataLoader.js';
9
+
10
+ const BUILTIN_PATTERNS = loadBuiltinJson('secret-patterns.json');
11
+ const USER_PATTERNS = loadUserYamlList('secrets.yaml');
12
+
13
+ const SECRET_PATTERNS = compilePatterns([...BUILTIN_PATTERNS, ...USER_PATTERNS]);
14
+
15
+ /**
16
+ * Compile raw pattern definitions into regex objects
17
+ * @param {Array<{pattern: string, name: string, severity: string}>} rawPatterns
18
+ * @returns {Array<{pattern: RegExp, name: string, severity: string}>}
19
+ */
20
+ function compilePatterns(rawPatterns) {
21
+ const compiled = [];
22
+ for (const entry of rawPatterns) {
23
+ try {
24
+ compiled.push({
25
+ pattern: new RegExp(entry.pattern),
26
+ name: entry.name,
27
+ severity: entry.severity,
28
+ });
29
+ } catch (_err) {
30
+ // skip malformed patterns from user overrides
31
+ }
32
+ }
33
+ return compiled;
34
+ }
35
+
36
+ /**
37
+ * Detect hardcoded secrets in server environment variables
38
+ * @param {object} envVars - Environment variables from server config
39
+ * @param {object} server - Server context (name, ide, configPath)
40
+ * @returns {Array} Findings
41
+ */
42
+ export function detectHardcodedSecrets(envVars, server) {
43
+ const findings = [];
44
+ if (!envVars || typeof envVars !== 'object') {
45
+ return findings;
46
+ }
47
+
48
+ for (const [key, value] of Object.entries(envVars)) {
49
+ if (typeof value !== 'string') {
50
+ continue;
51
+ }
52
+ if (value.startsWith('${')) {
53
+ continue;
54
+ }
55
+ if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
56
+ continue;
57
+ }
58
+
59
+ for (const { pattern, name, severity } of SECRET_PATTERNS) {
60
+ if (pattern.test(value)) {
61
+ const masked = maskSecret(value);
62
+ findings.push({
63
+ rule_id: 'hardcoded-secret',
64
+ category: 'MCP01',
65
+ severity,
66
+ confidence: 'definite',
67
+ title: `${name} hardcoded in config`,
68
+ description: `${key}=${masked} — use environment variable reference instead`,
69
+ server_name: server.name,
70
+ ide: server.ide,
71
+ config_path: server.configPath,
72
+ fixable: true,
73
+ fix_type: 'env_var_replacement',
74
+ fix_data: { key, original: value },
75
+ });
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ return findings;
82
+ }
83
+
84
+ /**
85
+ * Mask a secret value for display
86
+ */
87
+ function maskSecret(value) {
88
+ if (value.length <= 8) {
89
+ return '****';
90
+ }
91
+ return `${value.slice(0, 4)}****`;
92
+ }