@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,152 @@
1
+ /**
2
+ * Lock Diff Engine
3
+ * Computes and renders differences between lockfile and current MCP state.
4
+ * Used by both `lock --verify` and `diff` commands.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import kleur from 'kleur';
8
+ import { S } from './symbols.js';
9
+
10
+ /**
11
+ * Hash a tool definition using SHA-256
12
+ */
13
+ export function hashToolDefinition(tool) {
14
+ const canonical = JSON.stringify({
15
+ name: tool.name,
16
+ description: tool.description,
17
+ inputSchema: tool.inputSchema || tool.parameters,
18
+ });
19
+ return createHash('sha256').update(canonical).digest('hex');
20
+ }
21
+
22
+ /**
23
+ * Count parameters in a tool definition
24
+ */
25
+ export function countParameters(tool) {
26
+ const schema = tool.inputSchema || tool.parameters || {};
27
+ const properties = schema.properties || {};
28
+ return Object.keys(properties).length;
29
+ }
30
+
31
+ /**
32
+ * Normalize server.tools (array or name→definition map) to an array of tool objects
33
+ */
34
+ export function normalizeToolsList(tools) {
35
+ if (!tools) {
36
+ return [];
37
+ }
38
+ if (Array.isArray(tools)) {
39
+ return tools;
40
+ }
41
+ if (typeof tools === 'object') {
42
+ return Object.entries(tools).map(([name, def]) => {
43
+ if (def && typeof def === 'object' && !Array.isArray(def)) {
44
+ return { ...def, name: def.name || name };
45
+ }
46
+ return typeof def === 'string' ? { name, description: def } : { name };
47
+ });
48
+ }
49
+ return [];
50
+ }
51
+
52
+ /**
53
+ * Compute diff between lockfile and current state
54
+ * @param {object} lockData - Parsed lockfile data
55
+ * @param {Array} currentServers - Current servers from ConfigScanner
56
+ * @returns {Array} List of change objects
57
+ */
58
+ export function computeDiff(lockData, currentServers) {
59
+ const changes = [];
60
+
61
+ for (const server of currentServers) {
62
+ const locked = lockData.servers[server.name];
63
+ if (!locked) {
64
+ changes.push({ type: 'added_server', server: server.name, ide: server.ide });
65
+ continue;
66
+ }
67
+
68
+ const tools = normalizeToolsList(server.tools);
69
+ for (const tool of tools) {
70
+ const toolObj = typeof tool === 'string' ? { name: tool } : tool;
71
+ const toolName = toolObj.name || 'unknown';
72
+ const lockedTool = locked.tools[toolName];
73
+
74
+ if (!lockedTool) {
75
+ changes.push({ type: 'added_tool', server: server.name, tool: toolName });
76
+ continue;
77
+ }
78
+
79
+ const currentHash = `sha256:${hashToolDefinition(toolObj)}`;
80
+ if (currentHash !== lockedTool.hash) {
81
+ changes.push({ type: 'changed_tool', server: server.name, tool: toolName });
82
+ }
83
+ }
84
+
85
+ checkRemovedTools(locked, tools, server.name, changes);
86
+ }
87
+
88
+ checkRemovedServers(lockData, currentServers, changes);
89
+
90
+ return changes;
91
+ }
92
+
93
+ /**
94
+ * Check for tools that were in lockfile but removed from current state
95
+ */
96
+ function checkRemovedTools(locked, tools, serverName, changes) {
97
+ for (const lockedToolName of Object.keys(locked.tools)) {
98
+ const stillExists = tools.some((t) => {
99
+ const name = typeof t === 'string' ? t : t.name;
100
+ return name === lockedToolName;
101
+ });
102
+ if (!stillExists) {
103
+ changes.push({ type: 'removed_tool', server: serverName, tool: lockedToolName });
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check for servers that were in lockfile but removed
110
+ */
111
+ function checkRemovedServers(lockData, currentServers, changes) {
112
+ for (const lockedServerName of Object.keys(lockData.servers)) {
113
+ const stillExists = currentServers.some((s) => s.name === lockedServerName);
114
+ if (!stillExists) {
115
+ changes.push({ type: 'removed_server', server: lockedServerName });
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Render diff changes to terminal
122
+ */
123
+ export function renderDiff(changes) {
124
+ if (changes.length === 0) {
125
+ console.log(` ${kleur.green(S.pass)} No changes detected`);
126
+ return;
127
+ }
128
+
129
+ console.log('');
130
+ for (const change of changes) {
131
+ if (change.type === 'added_server') {
132
+ console.log(
133
+ ` ${kleur.green('+')} Server added: ${kleur.bold(change.server)} (${change.ide})`
134
+ );
135
+ }
136
+ if (change.type === 'removed_server') {
137
+ console.log(` ${kleur.red('-')} Server removed: ${kleur.bold(change.server)}`);
138
+ }
139
+ if (change.type === 'added_tool') {
140
+ console.log(` ${kleur.green('+')} Tool added: ${change.server}/${kleur.bold(change.tool)}`);
141
+ }
142
+ if (change.type === 'removed_tool') {
143
+ console.log(` ${kleur.red('-')} Tool removed: ${change.server}/${kleur.bold(change.tool)}`);
144
+ }
145
+ if (change.type === 'changed_tool') {
146
+ console.log(
147
+ ` ${kleur.yellow('~')} Tool changed: ${change.server}/${kleur.bold(change.tool)} ${kleur.yellow(S.warn)}`
148
+ );
149
+ }
150
+ }
151
+ console.log('');
152
+ }
@@ -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
+ }