@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,144 @@
1
+ import { Box, Text, useApp, useInput } from 'ink';
2
+ /**
3
+ * MCP Shark Interactive TUI — Main App Component
4
+ * Lazygit-style full-screen terminal interface
5
+ */
6
+ import { useEffect, useState } from 'react';
7
+ import { runScan } from '#core/cli/ScanService.js';
8
+ import { FindingsPanel } from './FindingsPanel.js';
9
+ import { FixPanel } from './FixPanel.js';
10
+ import { Header } from './Header.js';
11
+ import { HelpBar } from './HelpBar.js';
12
+ import { ServersPanel } from './ServersPanel.js';
13
+ import { ToxicFlowsPanel } from './ToxicFlowsPanel.js';
14
+ import { h } from './h.js';
15
+
16
+ const PANELS = ['findings', 'servers', 'flows', 'fix'];
17
+ const PANEL_LABELS = {
18
+ findings: 'Findings',
19
+ servers: 'Servers',
20
+ flows: 'Toxic Flows',
21
+ fix: 'Auto-Fix',
22
+ };
23
+
24
+ export function App() {
25
+ const { exit } = useApp();
26
+ const [scanResult, setScanResult] = useState(null);
27
+ const [activePanel, setActivePanel] = useState(0);
28
+ const [selectedIndex, setSelectedIndex] = useState(0);
29
+
30
+ useEffect(() => {
31
+ const result = runScan({});
32
+ setScanResult(result);
33
+ }, []);
34
+
35
+ const rescan = () => {
36
+ setScanResult(null);
37
+ const result = runScan({});
38
+ setScanResult(result);
39
+ setSelectedIndex(0);
40
+ };
41
+
42
+ useInput((input, key) => {
43
+ if (input === 'q' || (key.ctrl && input === 'c')) {
44
+ exit();
45
+ return;
46
+ }
47
+
48
+ if (key.tab || input === 'l') {
49
+ setActivePanel((prev) => (prev + 1) % PANELS.length);
50
+ setSelectedIndex(0);
51
+ return;
52
+ }
53
+
54
+ if (input === '1') {
55
+ setActivePanel(0);
56
+ setSelectedIndex(0);
57
+ }
58
+ if (input === '2') {
59
+ setActivePanel(1);
60
+ setSelectedIndex(0);
61
+ }
62
+ if (input === '3') {
63
+ setActivePanel(2);
64
+ setSelectedIndex(0);
65
+ }
66
+ if (input === '4') {
67
+ setActivePanel(3);
68
+ setSelectedIndex(0);
69
+ }
70
+
71
+ if (input === 'r') {
72
+ rescan();
73
+ return;
74
+ }
75
+
76
+ if (key.downArrow || input === 'j') {
77
+ setSelectedIndex((prev) => prev + 1);
78
+ }
79
+ if (key.upArrow || input === 'k') {
80
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
81
+ }
82
+ });
83
+
84
+ if (!scanResult) {
85
+ return h(
86
+ Box,
87
+ { flexDirection: 'column', padding: 1 },
88
+ h(Header, { score: null, grade: null }),
89
+ h(Box, { justifyContent: 'center', padding: 2 }, h(Text, { color: 'cyan' }, 'Scanning...'))
90
+ );
91
+ }
92
+
93
+ const panelName = PANELS[activePanel];
94
+
95
+ const tabs = PANELS.map((panel, idx) =>
96
+ h(
97
+ Box,
98
+ { key: panel, marginRight: 1 },
99
+ h(
100
+ Text,
101
+ {
102
+ bold: idx === activePanel,
103
+ color: idx === activePanel ? 'cyan' : 'gray',
104
+ inverse: idx === activePanel,
105
+ },
106
+ ` ${idx + 1}:${PANEL_LABELS[panel]} `
107
+ )
108
+ )
109
+ );
110
+
111
+ const panelProps = { selectedIndex };
112
+ let activeContent = null;
113
+
114
+ if (panelName === 'findings') {
115
+ activeContent = h(FindingsPanel, { findings: scanResult.findings, ...panelProps });
116
+ } else if (panelName === 'servers') {
117
+ activeContent = h(ServersPanel, {
118
+ servers: scanResult.servers,
119
+ findings: scanResult.findings,
120
+ ...panelProps,
121
+ });
122
+ } else if (panelName === 'flows') {
123
+ activeContent = h(ToxicFlowsPanel, { toxicFlows: scanResult.toxicFlows, ...panelProps });
124
+ } else if (panelName === 'fix') {
125
+ activeContent = h(FixPanel, { findings: scanResult.findings, onRescan: rescan });
126
+ }
127
+
128
+ return h(
129
+ Box,
130
+ { flexDirection: 'column', padding: 0 },
131
+ h(Header, {
132
+ score: scanResult.scoreResult.score,
133
+ grade: scanResult.scoreResult.grade,
134
+ serverCount: scanResult.serverCount,
135
+ findingCount: scanResult.findings.length,
136
+ flowCount: scanResult.toxicFlows.length,
137
+ ruleCount: scanResult.ruleCount,
138
+ elapsedMs: scanResult.elapsedMs,
139
+ }),
140
+ h(Box, { paddingX: 1 }, ...tabs),
141
+ h(Box, { flexDirection: 'column', paddingX: 1, flexGrow: 1 }, activeContent),
142
+ h(HelpBar, { activePanel: panelName })
143
+ );
144
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * TUI Findings Panel — Scrollable list of security findings with detail view
3
+ */
4
+ import { Box, Text } from 'ink';
5
+ import { h } from './h.js';
6
+
7
+ const SEVERITY_COLORS = { critical: 'red', high: 'yellow', medium: 'blue', low: 'gray' };
8
+ const SEVERITY_ICONS = { critical: '✖', high: '▲', medium: '●', low: '○' };
9
+ const VISIBLE_ROWS = 15;
10
+
11
+ export function FindingsPanel({ findings, selectedIndex }) {
12
+ if (findings.length === 0) {
13
+ return h(
14
+ Box,
15
+ { padding: 1 },
16
+ h(Text, { color: 'green' }, '✔ No security issues found. Your MCP setup looks clean.')
17
+ );
18
+ }
19
+
20
+ const clamped = Math.min(selectedIndex, findings.length - 1);
21
+ const scrollOffset = Math.max(0, clamped - VISIBLE_ROWS + 3);
22
+ const visible = findings.slice(scrollOffset, scrollOffset + VISIBLE_ROWS);
23
+ const selected = findings[clamped];
24
+
25
+ const rows = visible.map((finding, idx) => {
26
+ const actualIdx = scrollOffset + idx;
27
+ const isSelected = actualIdx === clamped;
28
+ const sev = (finding.severity || 'medium').toLowerCase();
29
+ const color = SEVERITY_COLORS[sev] || 'white';
30
+ const icon = SEVERITY_ICONS[sev] || '●';
31
+
32
+ return h(
33
+ Box,
34
+ { key: finding.rule_id + finding.title + actualIdx },
35
+ h(
36
+ Text,
37
+ { inverse: isSelected, color: isSelected ? 'cyan' : color },
38
+ `${isSelected ? '▸' : ' '} ${icon} ${sev.toUpperCase().padEnd(8)}`
39
+ ),
40
+ h(
41
+ Text,
42
+ { inverse: isSelected, color: isSelected ? 'white' : 'gray' },
43
+ ` ${truncate(finding.title, 70)}`
44
+ )
45
+ );
46
+ });
47
+
48
+ const scrollInfo =
49
+ findings.length > VISIBLE_ROWS
50
+ ? h(
51
+ Text,
52
+ { color: 'gray', dimColor: true },
53
+ `${scrollOffset > 0 ? '↑ ' : ' '}${scrollOffset + VISIBLE_ROWS < findings.length ? '↓ scroll' : ' end'} (${clamped + 1}/${findings.length})`
54
+ )
55
+ : null;
56
+
57
+ const detail = selected
58
+ ? h(
59
+ Box,
60
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'cyan', paddingX: 1 },
61
+ h(Text, { bold: true, color: 'white' }, ' Detail '),
62
+ h(Text, null, h(Text, { bold: true }, 'Rule:'), ` ${selected.rule_id || '—'}`),
63
+ h(
64
+ Text,
65
+ null,
66
+ h(Text, { bold: true }, 'Severity:'),
67
+ ' ',
68
+ h(
69
+ Text,
70
+ { color: SEVERITY_COLORS[(selected.severity || '').toLowerCase()] },
71
+ selected.severity
72
+ )
73
+ ),
74
+ h(Text, null, h(Text, { bold: true }, 'Server:'), ` ${selected.server_name || '—'}`),
75
+ h(Text, null, h(Text, { bold: true }, 'IDE:'), ` ${selected.ide || '—'}`),
76
+ h(
77
+ Text,
78
+ { wrap: 'wrap' },
79
+ h(Text, { bold: true }, 'Description:'),
80
+ ` ${selected.description || '—'}`
81
+ ),
82
+ selected.recommendation
83
+ ? h(
84
+ Text,
85
+ { wrap: 'wrap' },
86
+ h(Text, { bold: true }, 'Fix:'),
87
+ ` ${selected.recommendation}`
88
+ )
89
+ : null,
90
+ selected.fixable
91
+ ? h(Text, { color: 'green' }, '✔ Auto-fixable — press 4 to go to Fix panel')
92
+ : null
93
+ )
94
+ : null;
95
+
96
+ return h(
97
+ Box,
98
+ { flexDirection: 'column' },
99
+ h(
100
+ Box,
101
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
102
+ h(Text, { bold: true, color: 'white' }, ` Findings (${findings.length}) `),
103
+ ...rows,
104
+ scrollInfo
105
+ ),
106
+ detail
107
+ );
108
+ }
109
+
110
+ function truncate(str, maxLen) {
111
+ if (!str) {
112
+ return '';
113
+ }
114
+ return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
115
+ }
@@ -0,0 +1,132 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ /**
3
+ * TUI Fix Panel — Auto-fix interface with interactive selection
4
+ */
5
+ import { useState } from 'react';
6
+ import { applyFixes } from '#core/cli/AutoFixEngine.js';
7
+ import { h } from './h.js';
8
+
9
+ export function FixPanel({ findings, onRescan }) {
10
+ const fixable = findings.filter((f) => f.fixable);
11
+ const [fixResult, setFixResult] = useState(null);
12
+ const [confirming, setConfirming] = useState(false);
13
+
14
+ useInput((input) => {
15
+ if (confirming) {
16
+ if (input === 'y') {
17
+ try {
18
+ setFixResult(applyFixes(findings));
19
+ } catch (err) {
20
+ setFixResult({
21
+ fixed: [],
22
+ skipped: [],
23
+ errors: [{ success: false, error: err.message, finding: { title: 'Apply fixes' } }],
24
+ });
25
+ }
26
+ setConfirming(false);
27
+ }
28
+ if (input === 'n') {
29
+ setConfirming(false);
30
+ }
31
+ return;
32
+ }
33
+
34
+ if (input === 'f' && fixable.length > 0) {
35
+ setConfirming(true);
36
+ }
37
+ if (input === 'u') {
38
+ try {
39
+ setFixResult(applyFixes(findings, { undo: true }));
40
+ } catch (err) {
41
+ setFixResult({
42
+ fixed: [],
43
+ skipped: [],
44
+ errors: [{ success: false, error: err.message, finding: { title: 'Undo fixes' } }],
45
+ });
46
+ }
47
+ }
48
+ if (input === 'r') {
49
+ setFixResult(null);
50
+ onRescan();
51
+ }
52
+ });
53
+
54
+ if (fixable.length === 0 && !fixResult) {
55
+ return h(
56
+ Box,
57
+ { padding: 1, flexDirection: 'column' },
58
+ h(Text, { color: 'green' }, '✔ No auto-fixable issues found.'),
59
+ h(Text, { color: 'gray' }, 'All findings require manual remediation.')
60
+ );
61
+ }
62
+
63
+ const fixRows = fixable.map((finding) =>
64
+ h(
65
+ Box,
66
+ { key: `${finding.rule_id}-${finding.title}` },
67
+ h(Text, { color: 'green' }, ' ● '),
68
+ h(Text, { color: 'white' }, `${finding.fix_type} — `),
69
+ h(Text, { color: 'gray' }, truncate(finding.title, 60))
70
+ )
71
+ );
72
+
73
+ const prompt = confirming
74
+ ? h(
75
+ Text,
76
+ { bold: true, color: 'yellow' },
77
+ `Apply ${fixable.length} fixes? Backups will be created. (y/n)`
78
+ )
79
+ : h(
80
+ Text,
81
+ { color: 'gray' },
82
+ 'Press ',
83
+ h(Text, { bold: true, color: 'cyan' }, 'f'),
84
+ ' to fix all · ',
85
+ h(Text, { bold: true, color: 'cyan' }, 'u'),
86
+ ' to undo · ',
87
+ h(Text, { bold: true, color: 'cyan' }, 'r'),
88
+ ' to rescan'
89
+ );
90
+
91
+ const resultPanel = fixResult
92
+ ? h(
93
+ Box,
94
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'cyan', paddingX: 1 },
95
+ h(Text, { bold: true, color: 'white' }, ' Results '),
96
+ ...fixResult.fixed.map((fx) =>
97
+ h(Text, { key: `fix-${fx.message}`, color: 'green' }, ` ✔ ${fx.message}`)
98
+ ),
99
+ ...fixResult.errors.map((err) =>
100
+ h(Text, { key: `err-${err.error}`, color: 'red' }, ` ✖ ${err.error || err.reason}`)
101
+ ),
102
+ ...fixResult.skipped.map((sk) =>
103
+ h(Text, { key: `skip-${sk.reason}`, color: 'gray' }, ` ○ Skipped: ${sk.reason}`)
104
+ ),
105
+ h(
106
+ Text,
107
+ { bold: true },
108
+ ` ${fixResult.fixed.length} fixed · ${fixResult.errors.length} errors · ${fixResult.skipped.length} skipped`
109
+ )
110
+ )
111
+ : null;
112
+
113
+ return h(
114
+ Box,
115
+ { flexDirection: 'column' },
116
+ h(
117
+ Box,
118
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'green', paddingX: 1 },
119
+ h(Text, { bold: true, color: 'white' }, ` Auto-Fix (${fixable.length} fixable) `),
120
+ ...fixRows,
121
+ h(Box, { marginTop: 1 }, prompt)
122
+ ),
123
+ resultPanel
124
+ );
125
+ }
126
+
127
+ function truncate(str, maxLen) {
128
+ if (!str) {
129
+ return '';
130
+ }
131
+ return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
132
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * TUI Header — Score display and scan summary
3
+ */
4
+ import { Box, Text } from 'ink';
5
+ import { h } from './h.js';
6
+
7
+ const GRADE_COLORS = { A: 'green', B: 'green', C: 'yellow', D: 'yellow', F: 'red' };
8
+
9
+ export function Header({
10
+ score,
11
+ grade,
12
+ serverCount,
13
+ findingCount,
14
+ flowCount,
15
+ ruleCount,
16
+ elapsedMs,
17
+ }) {
18
+ const gradeColor = GRADE_COLORS[grade] || 'white';
19
+
20
+ const scoreDisplay =
21
+ score !== null
22
+ ? h(
23
+ '',
24
+ null,
25
+ h(Text, { bold: true }, 'Score: '),
26
+ h(Text, { bold: true, color: gradeColor }, `${score}/100 (${grade})`)
27
+ )
28
+ : h(Text, { color: 'gray' }, 'Scanning...');
29
+
30
+ const statsDisplay =
31
+ score !== null
32
+ ? h(
33
+ Text,
34
+ { color: 'gray' },
35
+ `${findingCount} findings · ${flowCount} flows · ${serverCount} servers · ${ruleCount} rules · ${elapsedMs}ms`
36
+ )
37
+ : null;
38
+
39
+ return h(
40
+ Box,
41
+ { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
42
+ h(
43
+ Box,
44
+ null,
45
+ h(Text, { bold: true, color: 'cyan' }, 'mcp-shark'),
46
+ h(Text, { color: 'gray' }, ' │ '),
47
+ scoreDisplay
48
+ ),
49
+ statsDisplay
50
+ );
51
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * TUI Help Bar — Keyboard shortcuts displayed at the bottom
3
+ */
4
+ import { Box, Text } from 'ink';
5
+ import { h } from './h.js';
6
+
7
+ const COMMON_KEYS = [
8
+ { key: 'j/k', desc: 'navigate' },
9
+ { key: 'Tab', desc: 'next panel' },
10
+ { key: '1-4', desc: 'panel' },
11
+ { key: 'r', desc: 'rescan' },
12
+ { key: 'q', desc: 'quit' },
13
+ ];
14
+
15
+ const PANEL_KEYS = {
16
+ findings: [],
17
+ servers: [],
18
+ flows: [],
19
+ fix: [
20
+ { key: 'f', desc: 'fix all' },
21
+ { key: 'u', desc: 'undo' },
22
+ ],
23
+ };
24
+
25
+ export function HelpBar({ activePanel }) {
26
+ const panelSpecific = PANEL_KEYS[activePanel] || [];
27
+ const allKeys = [...panelSpecific, ...COMMON_KEYS];
28
+
29
+ return h(
30
+ Box,
31
+ { paddingX: 1 },
32
+ ...allKeys.map(({ key, desc }, idx) =>
33
+ h(
34
+ Box,
35
+ { key, marginRight: 1 },
36
+ h(Text, { bold: true, color: 'cyan' }, key),
37
+ h(Text, { color: 'gray' }, ` ${desc}`),
38
+ idx < allKeys.length - 1 ? h(Text, { color: 'gray' }, ' │') : null
39
+ )
40
+ )
41
+ );
42
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * TUI Servers Panel — Server inventory with finding counts
3
+ */
4
+ import { Box, Text } from 'ink';
5
+ import { h } from './h.js';
6
+
7
+ export function ServersPanel({ servers, findings, selectedIndex }) {
8
+ if (servers.length === 0) {
9
+ return h(
10
+ Box,
11
+ { padding: 1 },
12
+ h(Text, { color: 'yellow' }, '\u25B2 No MCP servers detected across 15 IDEs.')
13
+ );
14
+ }
15
+
16
+ const clamped = Math.min(selectedIndex, servers.length - 1);
17
+ const selected = servers[clamped];
18
+ const selectedFindings = findings.filter((f) => f.server_name === selected?.name);
19
+
20
+ const rows = servers.map((server, idx) => {
21
+ const isSelected = idx === clamped;
22
+ const count = findings.filter((f) => f.server_name === server.name).length;
23
+ const countColor = count === 0 ? 'green' : count > 3 ? 'red' : 'yellow';
24
+
25
+ return h(
26
+ Box,
27
+ { key: `${server.name}-${server.ide}` },
28
+ h(
29
+ Text,
30
+ { inverse: isSelected, color: isSelected ? 'cyan' : 'white' },
31
+ `${isSelected ? '▸' : ' '} ${server.name.padEnd(25)}`
32
+ ),
33
+ h(Text, { inverse: isSelected, color: isSelected ? 'white' : 'gray' }, server.ide.padEnd(15)),
34
+ h(
35
+ Text,
36
+ { inverse: isSelected, color: isSelected ? 'white' : countColor },
37
+ count === 0 ? '✔ clean' : `${count} issues`
38
+ )
39
+ );
40
+ });
41
+
42
+ const findingRows = selectedFindings.slice(0, 5).map((f) =>
43
+ h(
44
+ Text,
45
+ {
46
+ key: `${f.rule_id}-${f.title}`,
47
+ color: (f.severity || '').toLowerCase() === 'critical' ? 'red' : 'yellow',
48
+ },
49
+ ` ${f.severity?.toUpperCase()} — ${f.title}`
50
+ )
51
+ );
52
+
53
+ const moreText =
54
+ selectedFindings.length > 5
55
+ ? h(Text, { color: 'gray' }, ` ... and ${selectedFindings.length - 5} more`)
56
+ : null;
57
+
58
+ const detail = selected
59
+ ? h(
60
+ Box,
61
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'cyan', paddingX: 1 },
62
+ h(Text, { bold: true, color: 'white' }, ` ${selected.name} `),
63
+ h(Text, null, h(Text, { bold: true }, 'IDE:'), ` ${selected.ide}`),
64
+ h(Text, null, h(Text, { bold: true }, 'Config:'), ` ${selected.configPath || '—'}`),
65
+ h(
66
+ Text,
67
+ null,
68
+ h(Text, { bold: true }, 'Transport:'),
69
+ ` ${detectTransport(selected.config)}`
70
+ ),
71
+ h(Text, null, h(Text, { bold: true }, 'Command:'), ` ${selected.config?.command || '—'}`),
72
+ h(
73
+ Text,
74
+ null,
75
+ h(Text, { bold: true }, 'Tools:'),
76
+ ` ${Array.isArray(selected.tools) ? selected.tools.length : '?'}`
77
+ ),
78
+ h(Text, null, h(Text, { bold: true }, 'Findings:'), ` ${selectedFindings.length}`),
79
+ selectedFindings.length > 0
80
+ ? h(Box, { flexDirection: 'column' }, ...findingRows, moreText)
81
+ : null
82
+ )
83
+ : null;
84
+
85
+ return h(
86
+ Box,
87
+ { flexDirection: 'column' },
88
+ h(
89
+ Box,
90
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
91
+ h(Text, { bold: true, color: 'white' }, ` Servers (${servers.length}) `),
92
+ ...rows
93
+ ),
94
+ detail
95
+ );
96
+ }
97
+
98
+ function detectTransport(config) {
99
+ if (!config) {
100
+ return 'unknown';
101
+ }
102
+ if (config.command) {
103
+ return 'stdio';
104
+ }
105
+ if (config.url) {
106
+ return config.transport || 'http';
107
+ }
108
+ return config.transport || 'unknown';
109
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * TUI Toxic Flows Panel — Cross-server attack path visualization
3
+ */
4
+ import { Box, Text } from 'ink';
5
+ import { h } from './h.js';
6
+
7
+ export function ToxicFlowsPanel({ toxicFlows, selectedIndex }) {
8
+ if (toxicFlows.length === 0) {
9
+ return h(
10
+ Box,
11
+ { padding: 1 },
12
+ h(Text, { color: 'green' }, '✔ No toxic cross-server flows detected.')
13
+ );
14
+ }
15
+
16
+ const clamped = Math.min(selectedIndex, toxicFlows.length - 1);
17
+ const selected = toxicFlows[clamped];
18
+
19
+ const rows = toxicFlows.map((flow, idx) => {
20
+ const isSelected = idx === clamped;
21
+ const riskColor = (flow.risk || '').toLowerCase() === 'high' ? 'red' : 'yellow';
22
+ const flowKey = `${flow.source}-${flow.sink}-${flow.rule || idx}`;
23
+
24
+ return h(
25
+ Box,
26
+ { key: flowKey },
27
+ h(
28
+ Text,
29
+ { inverse: isSelected, color: isSelected ? 'magenta' : riskColor },
30
+ `${isSelected ? '▸' : ' '} ${(flow.risk || 'MED').toUpperCase().padEnd(6)}`
31
+ ),
32
+ h(
33
+ Text,
34
+ { inverse: isSelected, color: isSelected ? 'white' : 'gray' },
35
+ ` ${flow.source || '?'} → ${flow.sink || '?'}`
36
+ ),
37
+ h(
38
+ Text,
39
+ { inverse: isSelected, color: isSelected ? 'white' : 'gray', dimColor: !isSelected },
40
+ ` — ${truncate(flow.scenario || '', 50)}`
41
+ )
42
+ );
43
+ });
44
+
45
+ const caps = selected?.capabilities
46
+ ? Object.entries(selected.capabilities)
47
+ .filter(([, v]) => v)
48
+ .map(([k]) => k)
49
+ .join(', ')
50
+ : '—';
51
+
52
+ const detail = selected
53
+ ? h(
54
+ Box,
55
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'magenta', paddingX: 1 },
56
+ h(Text, { bold: true, color: 'magenta' }, ' Flow Detail '),
57
+ h(
58
+ Text,
59
+ null,
60
+ h(Text, { bold: true, color: 'red' }, selected.source),
61
+ h(Text, { color: 'gray' }, ' → '),
62
+ h(Text, { bold: true, color: 'red' }, selected.sink)
63
+ ),
64
+ h(
65
+ Text,
66
+ null,
67
+ h(Text, { bold: true }, 'Risk:'),
68
+ ' ',
69
+ h(Text, { color: selected.risk === 'high' ? 'red' : 'yellow' }, selected.risk)
70
+ ),
71
+ h(Text, null, h(Text, { bold: true }, 'Rule:'), ` ${selected.rule || '—'}`),
72
+ h(
73
+ Text,
74
+ { wrap: 'wrap' },
75
+ h(Text, { bold: true }, 'Scenario:'),
76
+ ` ${selected.scenario || '—'}`
77
+ ),
78
+ h(Text, { wrap: 'wrap' }, h(Text, { bold: true }, 'Capabilities:'), ` ${caps}`)
79
+ )
80
+ : null;
81
+
82
+ return h(
83
+ Box,
84
+ { flexDirection: 'column' },
85
+ h(
86
+ Box,
87
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'magenta', paddingX: 1 },
88
+ h(Text, { bold: true, color: 'white' }, ` Toxic Flows (${toxicFlows.length}) `),
89
+ ...rows
90
+ ),
91
+ detail
92
+ );
93
+ }
94
+
95
+ function truncate(str, maxLen) {
96
+ if (!str) {
97
+ return '';
98
+ }
99
+ return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
100
+ }
package/core/tui/h.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * JSX-free helper for React.createElement
3
+ * Keeps TUI code readable without requiring a build step.
4
+ */
5
+ import { Fragment, createElement } from 'react';
6
+
7
+ export const h = createElement;
8
+ export const F = Fragment;