@mcp-shark/mcp-shark 1.5.13 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +482 -56
- package/bin/mcp-shark.js +146 -52
- package/core/cli/AutoFixEngine.js +93 -0
- package/core/cli/ConfigScanner.js +193 -0
- package/core/cli/DataLoader.js +200 -0
- package/core/cli/DeclarativeRuleEngine.js +363 -0
- package/core/cli/DoctorCommand.js +218 -0
- package/core/cli/FixHandlers.js +222 -0
- package/core/cli/HtmlReportGenerator.js +203 -0
- package/core/cli/IdeConfigPaths.js +175 -0
- package/core/cli/ListCommand.js +255 -0
- package/core/cli/LockCommand.js +164 -0
- package/core/cli/LockDiffEngine.js +152 -0
- package/core/cli/RuleRegistryConfig.js +131 -0
- package/core/cli/ScanCommand.js +244 -0
- package/core/cli/ScanService.js +200 -0
- package/core/cli/SecretDetector.js +92 -0
- package/core/cli/SharkScoreCalculator.js +109 -0
- package/core/cli/ToolClassifications.js +51 -0
- package/core/cli/ToxicFlowAnalyzer.js +212 -0
- package/core/cli/UpdateCommand.js +188 -0
- package/core/cli/WalkthroughGenerator.js +195 -0
- package/core/cli/WatchCommand.js +129 -0
- package/core/cli/YamlRuleEngine.js +197 -0
- package/core/cli/data/rule-packs/aauth-visibility.json +117 -0
- package/core/cli/data/rule-packs/agentic-security-2026.json +180 -0
- package/core/cli/data/rule-packs/general-security.json +173 -0
- package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
- package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
- package/core/cli/data/rule-sources.json +5 -0
- package/core/cli/data/secret-patterns.json +18 -0
- package/core/cli/data/tool-classifications.json +111 -0
- package/core/cli/data/toxic-flow-rules.json +47 -0
- package/core/cli/index.js +23 -0
- package/core/cli/output/Banner.js +52 -0
- package/core/cli/output/Formatter.js +183 -0
- package/core/cli/output/JsonFormatter.js +106 -0
- package/core/cli/output/index.js +16 -0
- package/core/cli/secureRegistryFetch.js +157 -0
- package/core/cli/symbols.js +16 -0
- package/core/configs/environment.js +3 -1
- package/core/configs/index.js +3 -64
- package/core/container/DependencyContainer.js +4 -1
- package/core/mcp-server/index.js +4 -1
- package/core/mcp-server/server/external/all.js +10 -3
- package/core/mcp-server/server/external/config.js +62 -5
- package/core/models/RequestFilters.js +3 -0
- package/core/repositories/PacketRepository.js +16 -0
- package/core/services/AuditService.js +2 -0
- package/core/services/ConfigService.js +9 -1
- package/core/services/ConfigTransformService.js +34 -2
- package/core/services/RequestService.js +58 -5
- package/core/services/ServerManagementService.js +59 -4
- package/core/services/security/StaticRulesService.js +69 -13
- package/core/services/security/TrafficAnalysisService.js +19 -1
- package/core/services/security/TrafficToxicFlowService.js +154 -0
- package/core/services/security/aauthGraph.js +199 -0
- package/core/services/security/aauthParser.js +274 -0
- package/core/services/security/aauthSelfTest.js +346 -0
- package/core/services/security/index.js +2 -1
- package/core/services/security/rules/index.js +25 -59
- package/core/services/security/rules/scans/configPermissions.js +91 -0
- package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
- package/core/services/security/rules/scans/insecureTransport.js +148 -0
- package/core/services/security/rules/scans/missingContainment.js +123 -0
- package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
- package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
- package/core/services/security/toolsListFromTrafficParser.js +70 -0
- package/core/tui/App.js +144 -0
- package/core/tui/FindingsPanel.js +115 -0
- package/core/tui/FixPanel.js +132 -0
- package/core/tui/Header.js +51 -0
- package/core/tui/HelpBar.js +42 -0
- package/core/tui/ServersPanel.js +109 -0
- package/core/tui/ToxicFlowsPanel.js +100 -0
- package/core/tui/h.js +8 -0
- package/core/tui/index.js +11 -0
- package/core/tui/render.js +22 -0
- package/package.json +24 -16
- package/ui/dist/assets/index-D6zDrtMV.js +81 -0
- package/ui/dist/index.html +1 -1
- package/ui/server/controllers/AauthController.js +279 -0
- package/ui/server/controllers/RequestController.js +12 -1
- package/ui/server/controllers/SecurityFindingsController.js +46 -1
- package/ui/server/routes/aauth.js +18 -0
- package/ui/server/routes/requests.js +8 -1
- package/ui/server/routes/security.js +5 -1
- package/ui/server/setup.js +224 -6
- package/ui/server/swagger/paths/components.js +55 -0
- package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
- package/ui/server/swagger/paths.js +2 -2
- package/ui/server/swagger/swagger.js +5 -2
- package/ui/server.js +1 -1
- package/ui/src/App.jsx +26 -52
- package/ui/src/PacketFilters.jsx +31 -1
- package/ui/src/PacketList.jsx +2 -2
- package/ui/src/Security.jsx +10 -0
- package/ui/src/TabNavigation.jsx +8 -0
- package/ui/src/components/AAuthBadge.jsx +92 -0
- package/ui/src/components/AauthExplorer/AauthExplorerGraph.jsx +231 -0
- package/ui/src/components/AauthExplorer/AauthExplorerView.jsx +387 -0
- package/ui/src/components/AauthExplorer/NodeDetailPanel.jsx +272 -0
- package/ui/src/components/App/ActionMenu.jsx +4 -31
- package/ui/src/components/App/ApiDocsButton.jsx +0 -1
- package/ui/src/components/App/ShutdownButton.jsx +0 -1
- package/ui/src/components/App/useAppState.js +19 -26
- package/ui/src/components/DetailsTab/AAuthIdentitySection.jsx +119 -0
- package/ui/src/components/DetailsTab/RequestDetailsSection.jsx +2 -0
- package/ui/src/components/DetailsTab/ResponseDetailsSection.jsx +2 -0
- package/ui/src/components/DetectedPathsList.jsx +1 -5
- package/ui/src/components/FileInput.jsx +0 -1
- package/ui/src/components/PacketFilters/AAuthPostureFilter.jsx +81 -0
- package/ui/src/components/RequestRow/RequestRowMain.jsx +7 -1
- package/ui/src/components/Security/AAuthPosturePanel.jsx +360 -0
- package/ui/src/components/Security/ScannerContent.jsx +33 -1
- package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
- package/ui/src/components/Security/securityApi.js +15 -0
- package/ui/src/components/Security/useSecurity.js +60 -3
- package/ui/src/components/ServerControl.jsx +0 -1
- package/ui/src/components/TabNavigation/DesktopTabs.jsx +0 -11
- package/ui/src/components/TabNavigationIcons.jsx +5 -0
- package/ui/src/components/ViewModeTabs.jsx +0 -1
- package/ui/src/utils/animations.js +26 -9
- package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
- package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
- package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
- package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
- package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
- package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
- package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
- package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
- package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
- package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
- package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
- package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
- package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
- package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
- package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
- package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
- package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
- package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
- package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
- package/ui/dist/assets/index-CiCSDYf-.js +0 -97
- package/ui/server/routes/help.js +0 -44
- package/ui/server/swagger/paths/help.js +0 -82
- package/ui/src/HelpGuide/HelpGuideContent.jsx +0 -118
- package/ui/src/HelpGuide/HelpGuideFooter.jsx +0 -59
- package/ui/src/HelpGuide/HelpGuideHeader.jsx +0 -57
- package/ui/src/HelpGuide.jsx +0 -78
- package/ui/src/IntroTour.jsx +0 -154
- package/ui/src/components/App/HelpButton.jsx +0 -90
- package/ui/src/components/TourOverlay.jsx +0 -117
- package/ui/src/components/TourTooltip/TourTooltipButtons.jsx +0 -120
- package/ui/src/components/TourTooltip/TourTooltipHeader.jsx +0 -71
- package/ui/src/components/TourTooltip/TourTooltipIcons.jsx +0 -54
- package/ui/src/components/TourTooltip/useTooltipPosition.js +0 -135
- package/ui/src/components/TourTooltip.jsx +0 -91
- package/ui/src/config/tourSteps.jsx +0 -140
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-server toxic-flow analysis from observed MCP traffic (tools/list responses).
|
|
3
|
+
* Complements CLI static scan: same analyzeToxicFlows heuristics, fed by proxy captures.
|
|
4
|
+
*/
|
|
5
|
+
import { analyzeToxicFlows } from '#core/cli/ToxicFlowAnalyzer.js';
|
|
6
|
+
import {
|
|
7
|
+
toolsFromJsonrpcResultString,
|
|
8
|
+
toolsFromTrafficResponseBody,
|
|
9
|
+
} from './toolsListFromTrafficParser.js';
|
|
10
|
+
|
|
11
|
+
const TRAFFIC_IDE = 'Traffic';
|
|
12
|
+
const DEBOUNCE_MS = 400;
|
|
13
|
+
|
|
14
|
+
function serverKey(mcpServerName, sessionId) {
|
|
15
|
+
if (mcpServerName && String(mcpServerName).trim()) {
|
|
16
|
+
return String(mcpServerName).trim();
|
|
17
|
+
}
|
|
18
|
+
if (sessionId && String(sessionId).trim()) {
|
|
19
|
+
return `session:${String(sessionId).trim()}`;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class TrafficToxicFlowService {
|
|
25
|
+
constructor(packetRepository, logger) {
|
|
26
|
+
this.packetRepository = packetRepository;
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
/** @type {Map<string, { tools: object[], updatedAt: number }>} */
|
|
29
|
+
this._byServer = new Map();
|
|
30
|
+
this._debounceTimer = null;
|
|
31
|
+
this._lastFlows = [];
|
|
32
|
+
this._lastComputedAt = null;
|
|
33
|
+
this._lastReplayPacketCount = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ingest a proxied JSON-RPC response (live path).
|
|
38
|
+
* @param {{ mcpServerName?: string|null, sessionId?: string|null, body?: unknown }} packetData
|
|
39
|
+
*/
|
|
40
|
+
ingestFromTrafficResponse(packetData) {
|
|
41
|
+
const tools = toolsFromTrafficResponseBody(packetData?.body);
|
|
42
|
+
if (!tools?.length) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const key = serverKey(packetData?.mcpServerName, packetData?.sessionId);
|
|
46
|
+
if (!key) {
|
|
47
|
+
this.logger?.debug?.('Traffic toxic flow: skip tools/list — no server name or session');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this._byServer.set(key, { tools, updatedAt: Date.now() });
|
|
51
|
+
this._scheduleRecompute();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_scheduleRecompute() {
|
|
55
|
+
if (this._debounceTimer) {
|
|
56
|
+
clearTimeout(this._debounceTimer);
|
|
57
|
+
}
|
|
58
|
+
this._debounceTimer = setTimeout(() => {
|
|
59
|
+
this._debounceTimer = null;
|
|
60
|
+
this._recomputeNow();
|
|
61
|
+
}, DEBOUNCE_MS);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_serversForAnalyzer() {
|
|
65
|
+
const servers = [];
|
|
66
|
+
for (const [name, entry] of this._byServer.entries()) {
|
|
67
|
+
servers.push({
|
|
68
|
+
name,
|
|
69
|
+
ide: TRAFFIC_IDE,
|
|
70
|
+
config: {},
|
|
71
|
+
tools: entry.tools,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return servers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_recomputeNow() {
|
|
78
|
+
const servers = this._serversForAnalyzer();
|
|
79
|
+
this._lastFlows = servers.length >= 2 ? analyzeToxicFlows(servers) : [];
|
|
80
|
+
this._lastComputedAt = Date.now();
|
|
81
|
+
this.logger?.debug?.(
|
|
82
|
+
{ serverCount: servers.length, flowCount: this._lastFlows.length },
|
|
83
|
+
'Traffic toxic flows recomputed'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Batch replay: rebuild registry from sqlite packets, then recompute.
|
|
89
|
+
* @returns {{ packetRows: number, serverCount: number, flowCount: number }}
|
|
90
|
+
*/
|
|
91
|
+
rebuildFromDatabase() {
|
|
92
|
+
if (!this.packetRepository?.listResponsesWithToolsList) {
|
|
93
|
+
this.logger?.warn?.('Traffic toxic flow replay: packet repository unavailable');
|
|
94
|
+
return { packetRows: 0, serverCount: 0, flowCount: 0 };
|
|
95
|
+
}
|
|
96
|
+
const rows = this.packetRepository.listResponsesWithToolsList();
|
|
97
|
+
this._byServer.clear();
|
|
98
|
+
this._lastReplayPacketCount = rows.length;
|
|
99
|
+
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
let tools = toolsFromJsonrpcResultString(row.jsonrpc_result);
|
|
102
|
+
if (!tools?.length) {
|
|
103
|
+
tools = toolsFromTrafficResponseBody(row.body_json || row.body_raw);
|
|
104
|
+
}
|
|
105
|
+
if (!tools?.length) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const key = serverKey(row.remote_address, row.session_id);
|
|
109
|
+
if (!key) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
this._byServer.set(key, { tools, updatedAt: Date.now() });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this._recomputeNow();
|
|
116
|
+
return {
|
|
117
|
+
packetRows: rows.length,
|
|
118
|
+
serverCount: this._byServer.size,
|
|
119
|
+
flowCount: this._lastFlows.length,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getSnapshot() {
|
|
124
|
+
const servers = [];
|
|
125
|
+
for (const [name, entry] of this._byServer.entries()) {
|
|
126
|
+
servers.push({
|
|
127
|
+
name,
|
|
128
|
+
toolCount: entry.tools.length,
|
|
129
|
+
updatedAt: entry.updatedAt,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
success: true,
|
|
134
|
+
toxicFlows: this._lastFlows,
|
|
135
|
+
servers,
|
|
136
|
+
computedAt: this._lastComputedAt,
|
|
137
|
+
lastReplayPacketCount: this._lastReplayPacketCount,
|
|
138
|
+
note:
|
|
139
|
+
'Heuristic cross-server pairs from tools seen in tools/list traffic (HTTP proxy). ' +
|
|
140
|
+
'Not runtime taint tracking. Requires at least two distinct server keys and overlapping capability rules.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clear() {
|
|
145
|
+
this._byServer.clear();
|
|
146
|
+
this._lastFlows = [];
|
|
147
|
+
this._lastComputedAt = null;
|
|
148
|
+
this._lastReplayPacketCount = 0;
|
|
149
|
+
if (this._debounceTimer) {
|
|
150
|
+
clearTimeout(this._debounceTimer);
|
|
151
|
+
this._debounceTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAuth knowledge-graph builder.
|
|
3
|
+
*
|
|
4
|
+
* Walks the captured packets, extracts AAuth signals via aauthParser, and
|
|
5
|
+
* shapes a node/edge graph that mirrors the categories on
|
|
6
|
+
* https://mcp-shark.github.io/aauth-explorer/ — but every node here is
|
|
7
|
+
* grounded in observed traffic, not a static spec illustration.
|
|
8
|
+
*
|
|
9
|
+
* Categories (kept stable so the UI can color-code them):
|
|
10
|
+
* - agent — observed AAuth-Agent values
|
|
11
|
+
* - mission — observed AAuth-Mission values
|
|
12
|
+
* - resource — destination host/server names
|
|
13
|
+
* - signing — algorithms parsed from Signature-Input (`alg=...`)
|
|
14
|
+
* - access — access modes parsed from AAuth-Requirement (`mode=...`)
|
|
15
|
+
*
|
|
16
|
+
* Edges are weighted by observed packet count so the force layout settles
|
|
17
|
+
* naturally around the busiest nodes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { parseAauthForPacket } from './aauthParser.js';
|
|
21
|
+
|
|
22
|
+
const ACCESS_MODE_REGEX = /mode\s*=\s*"?([A-Za-z0-9_-]+)"?/i;
|
|
23
|
+
|
|
24
|
+
function nodeKey(category, id) {
|
|
25
|
+
return `${category}::${id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureNode(map, category, id, extra = {}) {
|
|
29
|
+
const key = nodeKey(category, id);
|
|
30
|
+
if (!map.has(key)) {
|
|
31
|
+
map.set(key, {
|
|
32
|
+
id: key,
|
|
33
|
+
name: id,
|
|
34
|
+
category,
|
|
35
|
+
packet_count: 0,
|
|
36
|
+
sample_frame_numbers: [],
|
|
37
|
+
...extra,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return map.get(key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bumpNode(node, frameNumber) {
|
|
44
|
+
node.packet_count += 1;
|
|
45
|
+
if (frameNumber != null && node.sample_frame_numbers.length < 5) {
|
|
46
|
+
node.sample_frame_numbers.push(frameNumber);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureEdge(map, sourceKey, targetKey, kind) {
|
|
51
|
+
const key = `${sourceKey}->${targetKey}::${kind}`;
|
|
52
|
+
if (!map.has(key)) {
|
|
53
|
+
map.set(key, { id: key, source: sourceKey, target: targetKey, kind, weight: 0 });
|
|
54
|
+
}
|
|
55
|
+
return map.get(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bumpEdge(edge) {
|
|
59
|
+
edge.weight += 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractAccessMode(requirement) {
|
|
63
|
+
if (!requirement || typeof requirement !== 'string') {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const m = requirement.match(ACCESS_MODE_REGEX);
|
|
67
|
+
return m ? m[1] : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractResource(packet) {
|
|
71
|
+
const host = packet?.host || packet?.remote_address || null;
|
|
72
|
+
if (host) {
|
|
73
|
+
return host;
|
|
74
|
+
}
|
|
75
|
+
const url = packet?.url;
|
|
76
|
+
if (typeof url === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(url).host;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build an AAuth knowledge graph from a list of packets.
|
|
88
|
+
*
|
|
89
|
+
* @param {Array} packets - packets as returned by RequestService.getRequests
|
|
90
|
+
* @returns {{
|
|
91
|
+
* categories: Array<{id:string,label:string}>,
|
|
92
|
+
* nodes: Array,
|
|
93
|
+
* edges: Array,
|
|
94
|
+
* stats: object,
|
|
95
|
+
* }}
|
|
96
|
+
*/
|
|
97
|
+
export function buildAauthGraph(packets) {
|
|
98
|
+
const nodes = new Map();
|
|
99
|
+
const edges = new Map();
|
|
100
|
+
|
|
101
|
+
let observedSignals = 0;
|
|
102
|
+
|
|
103
|
+
for (const packet of packets || []) {
|
|
104
|
+
const aauth = parseAauthForPacket(packet);
|
|
105
|
+
const hasAnySignal =
|
|
106
|
+
aauth.posture !== 'none' ||
|
|
107
|
+
aauth.agent ||
|
|
108
|
+
aauth.mission ||
|
|
109
|
+
aauth.requirement ||
|
|
110
|
+
aauth.sig_alg;
|
|
111
|
+
if (!hasAnySignal) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
observedSignals += 1;
|
|
115
|
+
|
|
116
|
+
const resourceId = extractResource(packet);
|
|
117
|
+
const frame = packet?.frame_number ?? null;
|
|
118
|
+
|
|
119
|
+
const resourceNode = resourceId
|
|
120
|
+
? ensureNode(nodes, 'resource', resourceId, { url: packet?.url || null })
|
|
121
|
+
: null;
|
|
122
|
+
if (resourceNode) {
|
|
123
|
+
bumpNode(resourceNode, frame);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let agentNode = null;
|
|
127
|
+
if (aauth.agent) {
|
|
128
|
+
agentNode = ensureNode(nodes, 'agent', aauth.agent, {
|
|
129
|
+
last_keyid: aauth.sig_keyid,
|
|
130
|
+
posture: aauth.posture,
|
|
131
|
+
});
|
|
132
|
+
bumpNode(agentNode, frame);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let missionNode = null;
|
|
136
|
+
if (aauth.mission) {
|
|
137
|
+
missionNode = ensureNode(nodes, 'mission', aauth.mission);
|
|
138
|
+
bumpNode(missionNode, frame);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let signingNode = null;
|
|
142
|
+
if (aauth.sig_alg) {
|
|
143
|
+
signingNode = ensureNode(nodes, 'signing', aauth.sig_alg, {
|
|
144
|
+
keyid: aauth.sig_keyid,
|
|
145
|
+
});
|
|
146
|
+
bumpNode(signingNode, frame);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const accessMode = extractAccessMode(aauth.requirement);
|
|
150
|
+
let accessNode = null;
|
|
151
|
+
if (accessMode) {
|
|
152
|
+
accessNode = ensureNode(nodes, 'access', accessMode);
|
|
153
|
+
bumpNode(accessNode, frame);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (agentNode && resourceNode) {
|
|
157
|
+
bumpEdge(ensureEdge(edges, agentNode.id, resourceNode.id, 'calls'));
|
|
158
|
+
}
|
|
159
|
+
if (agentNode && missionNode) {
|
|
160
|
+
bumpEdge(ensureEdge(edges, agentNode.id, missionNode.id, 'pursues'));
|
|
161
|
+
}
|
|
162
|
+
if (missionNode && resourceNode) {
|
|
163
|
+
bumpEdge(ensureEdge(edges, missionNode.id, resourceNode.id, 'targets'));
|
|
164
|
+
}
|
|
165
|
+
if (agentNode && signingNode) {
|
|
166
|
+
bumpEdge(ensureEdge(edges, agentNode.id, signingNode.id, 'signs-with'));
|
|
167
|
+
}
|
|
168
|
+
if (resourceNode && accessNode) {
|
|
169
|
+
bumpEdge(ensureEdge(edges, resourceNode.id, accessNode.id, 'requires'));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const categories = [
|
|
174
|
+
{ id: 'agent', label: 'Agent' },
|
|
175
|
+
{ id: 'mission', label: 'Mission' },
|
|
176
|
+
{ id: 'resource', label: 'Resource' },
|
|
177
|
+
{ id: 'signing', label: 'Signing' },
|
|
178
|
+
{ id: 'access', label: 'Access' },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
categories,
|
|
183
|
+
nodes: [...nodes.values()],
|
|
184
|
+
edges: [...edges.values()],
|
|
185
|
+
stats: {
|
|
186
|
+
observed_packets: observedSignals,
|
|
187
|
+
node_counts: countByCategory([...nodes.values()]),
|
|
188
|
+
edge_count: edges.size,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function countByCategory(nodes) {
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const n of nodes) {
|
|
196
|
+
out[n.category] = (out[n.category] || 0) + 1;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAuth Visibility Parser
|
|
3
|
+
*
|
|
4
|
+
* Pure, observer-only parser for AAuth artifacts as they appear in HTTP
|
|
5
|
+
* headers and (incidentally) MCP server config. Does NOT perform any
|
|
6
|
+
* cryptographic verification — every signal it returns is described as
|
|
7
|
+
* "observed" or "present", never "valid".
|
|
8
|
+
*
|
|
9
|
+
* Specs informally referenced:
|
|
10
|
+
* - HTTP Message Signatures (RFC 9421) — Signature, Signature-Input
|
|
11
|
+
* - draft-hardt-http-signature-keys (AAuth) — Signature-Key, Signature-Error
|
|
12
|
+
* - draft-hardt-aauth-protocol — AAuth-Agent, AAuth-Mission,
|
|
13
|
+
* AAuth-Requirement
|
|
14
|
+
*
|
|
15
|
+
* The parser is intentionally liberal in what it accepts: AAuth drafts evolve,
|
|
16
|
+
* and visibility-only is the right place to record what is on the wire.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const AAUTH_AGENT_ID_REGEX = /aauth:[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
20
|
+
|
|
21
|
+
const SIGNATURE_HEADER_NAMES = ['signature', 'signature-input'];
|
|
22
|
+
const AAUTH_HEADER_PREFIX = 'aauth-';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lower-case all header keys, returning a new object.
|
|
26
|
+
* Tolerates non-object input by returning {}.
|
|
27
|
+
*/
|
|
28
|
+
function normalizeHeaders(headers) {
|
|
29
|
+
if (!headers || typeof headers !== 'object') {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const key of Object.keys(headers)) {
|
|
34
|
+
out[key.toLowerCase()] = headers[key];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a Signature-Input value into its first label's covered components and
|
|
41
|
+
* algorithm parameter. Format example:
|
|
42
|
+
* sig1=("@method" "@target-uri" "host");keyid="abc";alg="ed25519";created=1700000000
|
|
43
|
+
*
|
|
44
|
+
* Returns { label, covered, alg, keyid, created } or null on failure.
|
|
45
|
+
*/
|
|
46
|
+
function parseSignatureInput(value) {
|
|
47
|
+
if (!value || typeof value !== 'string') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const labelMatch = value.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*\(([^)]*)\)(.*)$/);
|
|
51
|
+
if (!labelMatch) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const [, label, componentsRaw, paramsRaw] = labelMatch;
|
|
55
|
+
const covered = [];
|
|
56
|
+
const componentRegex = /"([^"]+)"/g;
|
|
57
|
+
let m = componentRegex.exec(componentsRaw);
|
|
58
|
+
while (m) {
|
|
59
|
+
covered.push(m[1]);
|
|
60
|
+
m = componentRegex.exec(componentsRaw);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const params = {};
|
|
64
|
+
const paramRegex = /;\s*([A-Za-z0-9_-]+)=("([^"]*)"|([0-9]+))/g;
|
|
65
|
+
let pm = paramRegex.exec(paramsRaw);
|
|
66
|
+
while (pm) {
|
|
67
|
+
const [, key, , quoted, raw] = pm;
|
|
68
|
+
params[key] = quoted !== undefined ? quoted : raw;
|
|
69
|
+
pm = paramRegex.exec(paramsRaw);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
label,
|
|
74
|
+
covered,
|
|
75
|
+
alg: params.alg || null,
|
|
76
|
+
keyid: params.keyid || null,
|
|
77
|
+
created: params.created ? Number(params.created) : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Truncate a key/thumbprint string to a UI-friendly preview while keeping
|
|
83
|
+
* enough characters to be visually distinguishable.
|
|
84
|
+
*/
|
|
85
|
+
function shortFingerprint(value) {
|
|
86
|
+
if (!value || typeof value !== 'string') {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const trimmed = value.replace(/^"|"$/g, '').trim();
|
|
90
|
+
if (trimmed.length <= 16) {
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
return `${trimmed.slice(0, 8)}…${trimmed.slice(-4)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract any AAuth agent identifier(s) from a string. Returns the first match
|
|
98
|
+
* or null. Matches the form `aauth:<local>@<domain>` from the AAuth draft.
|
|
99
|
+
*/
|
|
100
|
+
export function extractAgentId(text) {
|
|
101
|
+
if (!text || typeof text !== 'string') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
AAUTH_AGENT_ID_REGEX.lastIndex = 0;
|
|
105
|
+
const m = text.match(AAUTH_AGENT_ID_REGEX);
|
|
106
|
+
return m ? m[0] : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse AAuth signals from a single packet's headers.
|
|
111
|
+
*
|
|
112
|
+
* Returns a normalized envelope. Every field is purely observational; the
|
|
113
|
+
* caller MUST NOT treat any of these values as cryptographically validated.
|
|
114
|
+
*
|
|
115
|
+
* @param {object|string|null|undefined} headers
|
|
116
|
+
* @returns {{
|
|
117
|
+
* posture: 'signed' | 'aauth-aware' | 'bearer' | 'none',
|
|
118
|
+
* sig_present: boolean,
|
|
119
|
+
* sig_alg: string | null,
|
|
120
|
+
* sig_keyid: string | null,
|
|
121
|
+
* sig_keyid_short: string | null,
|
|
122
|
+
* sig_covered: string[],
|
|
123
|
+
* key_thumbprint: string | null,
|
|
124
|
+
* key_thumbprint_short: string | null,
|
|
125
|
+
* agent: string | null,
|
|
126
|
+
* mission: string | null,
|
|
127
|
+
* requirement: string | null,
|
|
128
|
+
* error: string | null,
|
|
129
|
+
* raw: Record<string, string>
|
|
130
|
+
* }}
|
|
131
|
+
*/
|
|
132
|
+
export function parseAauthHeaders(headers) {
|
|
133
|
+
const h = normalizeHeaders(headers);
|
|
134
|
+
|
|
135
|
+
const sigInputRaw = h['signature-input'] || null;
|
|
136
|
+
const sigRaw = h.signature || null;
|
|
137
|
+
const sigKeyRaw = h['signature-key'] || null;
|
|
138
|
+
const sigErrorRaw = h['signature-error'] || null;
|
|
139
|
+
const agentRaw = h['aauth-agent'] || null;
|
|
140
|
+
const missionRaw = h['aauth-mission'] || null;
|
|
141
|
+
const requirementRaw = h['aauth-requirement'] || h['www-authenticate-aauth'] || null;
|
|
142
|
+
const authzRaw = h.authorization || null;
|
|
143
|
+
|
|
144
|
+
const parsedInput = parseSignatureInput(sigInputRaw);
|
|
145
|
+
|
|
146
|
+
const sigPresent = Boolean(sigRaw && sigInputRaw);
|
|
147
|
+
const sigAlg = parsedInput?.alg || null;
|
|
148
|
+
const sigKeyid = parsedInput?.keyid || null;
|
|
149
|
+
const sigCovered = parsedInput?.covered || [];
|
|
150
|
+
|
|
151
|
+
const agent = agentRaw || extractAgentId(authzRaw) || extractAgentId(sigKeyRaw) || null;
|
|
152
|
+
|
|
153
|
+
const aauthHeaderNames = Object.keys(h).filter((k) => k.startsWith(AAUTH_HEADER_PREFIX));
|
|
154
|
+
const aauthAware = aauthHeaderNames.length > 0 || Boolean(sigKeyRaw);
|
|
155
|
+
|
|
156
|
+
let posture;
|
|
157
|
+
if (sigPresent) {
|
|
158
|
+
posture = 'signed';
|
|
159
|
+
} else if (aauthAware) {
|
|
160
|
+
posture = 'aauth-aware';
|
|
161
|
+
} else if (authzRaw && /^bearer\s/i.test(authzRaw)) {
|
|
162
|
+
posture = 'bearer';
|
|
163
|
+
} else {
|
|
164
|
+
posture = 'none';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const raw = {};
|
|
168
|
+
for (const name of [
|
|
169
|
+
...SIGNATURE_HEADER_NAMES,
|
|
170
|
+
'signature-key',
|
|
171
|
+
'signature-error',
|
|
172
|
+
'aauth-agent',
|
|
173
|
+
'aauth-mission',
|
|
174
|
+
'aauth-requirement',
|
|
175
|
+
]) {
|
|
176
|
+
if (h[name]) {
|
|
177
|
+
raw[name] = h[name];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
posture,
|
|
183
|
+
sig_present: sigPresent,
|
|
184
|
+
sig_alg: sigAlg,
|
|
185
|
+
sig_keyid: sigKeyid,
|
|
186
|
+
sig_keyid_short: shortFingerprint(sigKeyid),
|
|
187
|
+
sig_covered: sigCovered,
|
|
188
|
+
key_thumbprint: sigKeyRaw,
|
|
189
|
+
key_thumbprint_short: shortFingerprint(sigKeyRaw),
|
|
190
|
+
agent,
|
|
191
|
+
mission: missionRaw,
|
|
192
|
+
requirement: requirementRaw,
|
|
193
|
+
error: sigErrorRaw,
|
|
194
|
+
raw,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Parse AAuth signals from a packet record (shape returned by PacketRepository).
|
|
200
|
+
* Reads `headers_json` (string or object).
|
|
201
|
+
*/
|
|
202
|
+
export function parseAauthForPacket(packet) {
|
|
203
|
+
if (!packet) {
|
|
204
|
+
return parseAauthHeaders(null);
|
|
205
|
+
}
|
|
206
|
+
let headers = packet.headers_json || packet.headers;
|
|
207
|
+
if (typeof headers === 'string') {
|
|
208
|
+
try {
|
|
209
|
+
headers = JSON.parse(headers);
|
|
210
|
+
} catch {
|
|
211
|
+
headers = {};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return parseAauthHeaders(headers);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Summarize AAuth posture across a list of packets. Useful for the inventory
|
|
219
|
+
* panel and CLI summary line.
|
|
220
|
+
*
|
|
221
|
+
* Returns counts by posture and the set of unique agents/missions observed.
|
|
222
|
+
*/
|
|
223
|
+
export function summarizeAauth(packets) {
|
|
224
|
+
const counts = { signed: 0, 'aauth-aware': 0, bearer: 0, none: 0 };
|
|
225
|
+
const agents = new Set();
|
|
226
|
+
const missions = new Set();
|
|
227
|
+
let total = 0;
|
|
228
|
+
|
|
229
|
+
for (const packet of packets || []) {
|
|
230
|
+
const aauth = parseAauthForPacket(packet);
|
|
231
|
+
counts[aauth.posture] = (counts[aauth.posture] || 0) + 1;
|
|
232
|
+
total += 1;
|
|
233
|
+
if (aauth.agent) {
|
|
234
|
+
agents.add(aauth.agent);
|
|
235
|
+
}
|
|
236
|
+
if (aauth.mission) {
|
|
237
|
+
missions.add(aauth.mission);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
total,
|
|
243
|
+
counts,
|
|
244
|
+
unique_agents: [...agents],
|
|
245
|
+
unique_missions: [...missions],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Decide whether a config-level server entry advertises AAuth in any way we
|
|
251
|
+
* can recognize without a network call. This is intentionally narrow: presence
|
|
252
|
+
* of an `aauth_id`, a JWKS URL, or a `.well-known/aauth` URL.
|
|
253
|
+
*
|
|
254
|
+
* Returns null if no signal, otherwise an object describing what we saw.
|
|
255
|
+
*/
|
|
256
|
+
export function inspectServerConfigForAauth(serverConfig) {
|
|
257
|
+
if (!serverConfig || typeof serverConfig !== 'object') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const blob = JSON.stringify(serverConfig);
|
|
261
|
+
const aauthId = extractAgentId(blob);
|
|
262
|
+
const wellKnown = blob.match(/https?:\/\/[^"'\s]+\/\.well-known\/aauth[^"'\s]*/);
|
|
263
|
+
const jwksUrl = blob.match(/https?:\/\/[^"'\s]+\/jwks(?:\.json)?[^"'\s]*/i);
|
|
264
|
+
|
|
265
|
+
if (!aauthId && !wellKnown && !jwksUrl) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
agent: aauthId || null,
|
|
271
|
+
well_known_url: wellKnown ? wellKnown[0] : null,
|
|
272
|
+
jwks_url: jwksUrl ? jwksUrl[0] : null,
|
|
273
|
+
};
|
|
274
|
+
}
|