@mcp-shark/mcp-shark 1.6.0 → 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 (68) hide show
  1. package/README.md +140 -51
  2. package/core/cli/ListCommand.js +31 -0
  3. package/core/cli/data/rule-packs/aauth-visibility.json +117 -0
  4. package/core/configs/environment.js +3 -1
  5. package/core/configs/index.js +3 -64
  6. package/core/mcp-server/index.js +4 -1
  7. package/core/mcp-server/server/external/all.js +10 -3
  8. package/core/mcp-server/server/external/config.js +62 -5
  9. package/core/models/RequestFilters.js +3 -0
  10. package/core/services/ConfigService.js +9 -1
  11. package/core/services/ConfigTransformService.js +34 -2
  12. package/core/services/RequestService.js +58 -5
  13. package/core/services/ServerManagementService.js +59 -4
  14. package/core/services/security/aauthGraph.js +199 -0
  15. package/core/services/security/aauthParser.js +274 -0
  16. package/core/services/security/aauthSelfTest.js +346 -0
  17. package/package.json +2 -2
  18. package/ui/dist/assets/index-D6zDrtMV.js +81 -0
  19. package/ui/dist/index.html +1 -1
  20. package/ui/server/controllers/AauthController.js +279 -0
  21. package/ui/server/controllers/RequestController.js +3 -0
  22. package/ui/server/routes/aauth.js +18 -0
  23. package/ui/server/setup.js +222 -6
  24. package/ui/server/swagger/paths.js +0 -2
  25. package/ui/server/swagger/swagger.js +0 -1
  26. package/ui/server.js +1 -1
  27. package/ui/src/App.jsx +26 -52
  28. package/ui/src/PacketFilters.jsx +31 -1
  29. package/ui/src/PacketList.jsx +2 -2
  30. package/ui/src/TabNavigation.jsx +8 -0
  31. package/ui/src/components/AAuthBadge.jsx +92 -0
  32. package/ui/src/components/AauthExplorer/AauthExplorerGraph.jsx +231 -0
  33. package/ui/src/components/AauthExplorer/AauthExplorerView.jsx +387 -0
  34. package/ui/src/components/AauthExplorer/NodeDetailPanel.jsx +272 -0
  35. package/ui/src/components/App/ActionMenu.jsx +4 -31
  36. package/ui/src/components/App/ApiDocsButton.jsx +0 -1
  37. package/ui/src/components/App/ShutdownButton.jsx +0 -1
  38. package/ui/src/components/App/useAppState.js +19 -26
  39. package/ui/src/components/DetailsTab/AAuthIdentitySection.jsx +119 -0
  40. package/ui/src/components/DetailsTab/RequestDetailsSection.jsx +2 -0
  41. package/ui/src/components/DetailsTab/ResponseDetailsSection.jsx +2 -0
  42. package/ui/src/components/DetectedPathsList.jsx +1 -5
  43. package/ui/src/components/FileInput.jsx +0 -1
  44. package/ui/src/components/PacketFilters/AAuthPostureFilter.jsx +81 -0
  45. package/ui/src/components/RequestRow/RequestRowMain.jsx +7 -1
  46. package/ui/src/components/Security/AAuthPosturePanel.jsx +360 -0
  47. package/ui/src/components/Security/ScannerContent.jsx +3 -0
  48. package/ui/src/components/ServerControl.jsx +0 -1
  49. package/ui/src/components/TabNavigation/DesktopTabs.jsx +0 -11
  50. package/ui/src/components/TabNavigationIcons.jsx +5 -0
  51. package/ui/src/components/ViewModeTabs.jsx +0 -1
  52. package/ui/src/utils/animations.js +26 -9
  53. package/ui/dist/assets/index-Buah4fNS.js +0 -97
  54. package/ui/server/routes/help.js +0 -44
  55. package/ui/server/swagger/paths/help.js +0 -82
  56. package/ui/src/HelpGuide/HelpGuideContent.jsx +0 -118
  57. package/ui/src/HelpGuide/HelpGuideFooter.jsx +0 -59
  58. package/ui/src/HelpGuide/HelpGuideHeader.jsx +0 -57
  59. package/ui/src/HelpGuide.jsx +0 -78
  60. package/ui/src/IntroTour.jsx +0 -154
  61. package/ui/src/components/App/HelpButton.jsx +0 -90
  62. package/ui/src/components/TourOverlay.jsx +0 -117
  63. package/ui/src/components/TourTooltip/TourTooltipButtons.jsx +0 -120
  64. package/ui/src/components/TourTooltip/TourTooltipHeader.jsx +0 -71
  65. package/ui/src/components/TourTooltip/TourTooltipIcons.jsx +0 -54
  66. package/ui/src/components/TourTooltip/useTooltipPosition.js +0 -135
  67. package/ui/src/components/TourTooltip.jsx +0 -91
  68. package/ui/src/config/tourSteps.jsx +0 -140
@@ -4,6 +4,36 @@ import { ConfigParserFactory } from '#core/services/parsers/ConfigParserFactory.
4
4
 
5
5
  const DEFAULT_TYPE = 'stdio';
6
6
 
7
+ // An upstream entry is considered a self-reference (and pruned) when:
8
+ // - its name is in this reserved set, OR
9
+ // - it is an HTTP entry whose host:port resolves to mcp-shark's own
10
+ // listening port. Including such an entry would loop the proxy back
11
+ // onto itself at startup and crash with RunAllExternalServersError.
12
+ const RESERVED_UPSTREAM_NAMES = new Set(['mcp-shark']);
13
+
14
+ function isSelfReferencingEntry(name, cfg, selfPort) {
15
+ if (RESERVED_UPSTREAM_NAMES.has(name)) {
16
+ return { selfRef: true, reason: `reserved name '${name}'` };
17
+ }
18
+ if (!selfPort || !cfg || typeof cfg.url !== 'string') {
19
+ return { selfRef: false };
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(cfg.url);
24
+ } catch {
25
+ return { selfRef: false };
26
+ }
27
+ const port = Number.parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
28
+ if (port !== selfPort) return { selfRef: false };
29
+ const localHosts = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1']);
30
+ if (!localHosts.has(parsed.hostname)) return { selfRef: false };
31
+ return {
32
+ selfRef: true,
33
+ reason: `URL ${parsed.hostname}:${port} points back at mcp-shark's own proxy`,
34
+ };
35
+ }
36
+
7
37
  export class ConfigError extends CompositeError {
8
38
  constructor(message, error) {
9
39
  super('ConfigError', message, error);
@@ -37,7 +67,8 @@ function parseConfig(configPath) {
37
67
  }
38
68
  }
39
69
 
40
- export function normalizeConfig(configPath) {
70
+ export function normalizeConfig(configPath, options = {}) {
71
+ const { selfPort, logger, allowEmpty = false } = options;
41
72
  const parsedConfigResult = parseConfig(configPath);
42
73
  if (isError(parsedConfigResult)) {
43
74
  return parsedConfigResult;
@@ -45,29 +76,55 @@ export function normalizeConfig(configPath) {
45
76
 
46
77
  const normalized = parserFactory.normalizeToInternalFormat(parsedConfigResult, configPath);
47
78
  if (!normalized) {
79
+ if (allowEmpty) return {};
48
80
  return new ConfigError('No servers found in config');
49
81
  }
50
82
 
51
83
  // Convert to flat map format expected by MCP server
52
84
  const out = new Map();
85
+ const pruned = [];
86
+
87
+ function addEntry(name, cfg) {
88
+ const { selfRef, reason } = isSelfReferencingEntry(name, cfg, selfPort);
89
+ if (selfRef) {
90
+ pruned.push({ name, reason });
91
+ return;
92
+ }
93
+ const type = cfg.type ?? DEFAULT_TYPE;
94
+ out.set(name, { type, ...cfg });
95
+ }
53
96
 
54
97
  // Handle normalized mcpServers format
55
98
  if (normalized.mcpServers) {
56
99
  for (const [name, cfg] of Object.entries(normalized.mcpServers)) {
57
- const type = cfg.type ?? DEFAULT_TYPE;
58
- out.set(name, { type, ...cfg });
100
+ addEntry(name, cfg);
59
101
  }
60
102
  }
61
103
 
62
104
  // Handle normalized servers format (legacy)
63
105
  if (normalized.servers) {
64
106
  for (const [name, cfg] of Object.entries(normalized.servers)) {
65
- const type = cfg.type ?? DEFAULT_TYPE;
66
- out.set(name, { type, ...cfg });
107
+ addEntry(name, cfg);
108
+ }
109
+ }
110
+
111
+ if (pruned.length > 0 && logger?.warn) {
112
+ for (const { name, reason } of pruned) {
113
+ logger.warn(
114
+ { upstream: name, reason },
115
+ `[MCP-Shark] Skipping self-referencing upstream '${name}': ${reason}`
116
+ );
67
117
  }
68
118
  }
69
119
 
70
120
  if (out.size === 0) {
121
+ if (allowEmpty) {
122
+ logger?.warn?.(
123
+ { configPath },
124
+ '[MCP-Shark] No upstream MCP servers configured — running in monitoring-only mode'
125
+ );
126
+ return {};
127
+ }
71
128
  return new ConfigError('No servers found in config');
72
129
  }
73
130
 
@@ -19,6 +19,9 @@ export class RequestFilters {
19
19
  this.limit = data.limit !== undefined ? Number.parseInt(data.limit) : Defaults.DEFAULT_LIMIT;
20
20
  this.offset =
21
21
  data.offset !== undefined ? Number.parseInt(data.offset) : Defaults.DEFAULT_OFFSET;
22
+ this.aauthPosture = data.aauthPosture || null;
23
+ this.aauthAgent = data.aauthAgent || null;
24
+ this.aauthMission = data.aauthMission || null;
22
25
  }
23
26
 
24
27
  /**
@@ -201,7 +201,15 @@ export class ConfigService {
201
201
  ? this.transformService.filterServers(baseConvertedConfig, selectedServices)
202
202
  : baseConvertedConfig;
203
203
 
204
- if (Object.keys(convertedConfig.servers || {}).length === 0) {
204
+ // A zero-upstream config is a legitimate state (monitoring-only mode):
205
+ // mcp-shark still runs the audit/UI/AAuth surfaces, just without any
206
+ // upstreams to forward to. We only fail here if the *original* config
207
+ // file declared no servers at all (different from "all servers were
208
+ // self-references and got filtered out").
209
+ const originalHadAnyServer =
210
+ Object.keys(originalConfig.mcpServers || {}).length > 0 ||
211
+ Object.keys(originalConfig.servers || {}).length > 0;
212
+ if (!originalHadAnyServer) {
205
213
  return {
206
214
  success: false,
207
215
  error: 'No servers found in config',
@@ -1,3 +1,29 @@
1
+ // mcp-shark must never appear as an upstream of itself. The UI reads the
2
+ // user's editor MCP config (e.g. ~/.cursor/mcp.json) — which legitimately
3
+ // contains an `mcp-shark` entry pointing at the proxy port so the editor
4
+ // routes calls through us — and copies it into ~/.mcp-shark/mcps.json as
5
+ // the proxy's upstream list. Without this filter the proxy ends up with a
6
+ // recursive self-reference that crashes startup.
7
+ const RESERVED_PROXY_NAMES = new Set(['mcp-shark']);
8
+ function isProxySelfReference(name, cfg) {
9
+ if (RESERVED_PROXY_NAMES.has(name)) return true;
10
+ if (!cfg || typeof cfg.url !== 'string') return false;
11
+ let parsed;
12
+ try {
13
+ parsed = new URL(cfg.url);
14
+ } catch {
15
+ return false;
16
+ }
17
+ const localHosts = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1']);
18
+ if (!localHosts.has(parsed.hostname)) return false;
19
+ const port = Number.parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
20
+ // We don't have access to the runtime port here; the proxy default is
21
+ // 9851 and the UI always starts it on that port unless overridden via
22
+ // env. If the user runs on a different port they can still safely include
23
+ // a custom-named entry; the runtime config.js layer will catch true loops.
24
+ return port === 9851;
25
+ }
26
+
1
27
  /**
2
28
  * Service for configuration transformations
3
29
  * Handles converting, filtering, and updating config structures
@@ -9,7 +35,9 @@ export class ConfigTransformService {
9
35
 
10
36
  /**
11
37
  * Convert MCP servers format to servers format
12
- * Normalizes config first, then converts mcpServers to servers
38
+ * Normalizes config first, then converts mcpServers to servers.
39
+ * Self-referencing entries (mcp-shark itself) are dropped — they would
40
+ * create an infinite proxy loop if written to ~/.mcp-shark/mcps.json.
13
41
  */
14
42
  convertMcpServersToServers(config) {
15
43
  // Normalize config to ensure consistent format
@@ -22,12 +50,16 @@ export class ConfigTransformService {
22
50
 
23
51
  // Handle normalized servers (legacy format)
24
52
  if (normalized.servers) {
25
- converted.servers = { ...normalized.servers };
53
+ for (const [name, cfg] of Object.entries(normalized.servers)) {
54
+ if (isProxySelfReference(name, cfg)) continue;
55
+ converted.servers[name] = cfg;
56
+ }
26
57
  }
27
58
 
28
59
  // Convert mcpServers to servers format
29
60
  if (normalized.mcpServers) {
30
61
  for (const [name, cfg] of Object.entries(normalized.mcpServers)) {
62
+ if (isProxySelfReference(name, cfg)) continue;
31
63
  const type = cfg.type || (cfg.url ? 'http' : cfg.command ? 'stdio' : 'stdio');
32
64
  converted.servers[name] = { type, ...cfg };
33
65
  }
@@ -5,6 +5,53 @@ import { Defaults } from '../constants/Defaults.js';
5
5
  * HTTP-agnostic: accepts models, returns models
6
6
  */
7
7
  import { RequestFilters } from '../models/RequestFilters.js';
8
+ import { parseAauthForPacket } from './security/aauthParser.js';
9
+
10
+ /**
11
+ * Attach an `aauth` field to a packet record by parsing its headers_json.
12
+ * Pure function; no DB writes. Always returns a new object.
13
+ */
14
+ function enrichWithAauth(packet) {
15
+ if (!packet) {
16
+ return packet;
17
+ }
18
+ try {
19
+ return { ...packet, aauth: parseAauthForPacket(packet) };
20
+ } catch {
21
+ return packet;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Filter a list of packets according to AAuth-derived filters that are not
27
+ * supported by the repository (which only knows about DB columns).
28
+ */
29
+ function applyAauthFilters(packets, filters) {
30
+ if (!filters) {
31
+ return packets;
32
+ }
33
+ const { aauthPosture = null, aauthAgent = null, aauthMission = null } = filters;
34
+ if (!aauthPosture && !aauthAgent && !aauthMission) {
35
+ return packets;
36
+ }
37
+
38
+ return packets.filter((p) => {
39
+ const aauth = p?.aauth;
40
+ if (!aauth) {
41
+ return false;
42
+ }
43
+ if (aauthPosture && aauth.posture !== aauthPosture) {
44
+ return false;
45
+ }
46
+ if (aauthAgent && aauth.agent !== aauthAgent) {
47
+ return false;
48
+ }
49
+ if (aauthMission && aauth.mission !== aauthMission) {
50
+ return false;
51
+ }
52
+ return true;
53
+ });
54
+ }
8
55
 
9
56
  export class RequestService {
10
57
  constructor(packetRepository) {
@@ -14,21 +61,27 @@ export class RequestService {
14
61
  /**
15
62
  * Get requests with filters
16
63
  * @param {RequestFilters} filters - Typed filter model
17
- * @returns {Array} Array of packet objects (raw from repository)
64
+ * @returns {Array} Array of packet objects, each enriched with an `aauth` block.
18
65
  */
19
66
  getRequests(filters) {
20
67
  const repoFilters = filters.toRepositoryFilters();
21
- return this.packetRepository.queryRequests(repoFilters);
68
+ const rows = this.packetRepository.queryRequests(repoFilters);
69
+ const enriched = rows.map(enrichWithAauth);
70
+ return applyAauthFilters(enriched, {
71
+ aauthPosture: filters.aauthPosture,
72
+ aauthAgent: filters.aauthAgent,
73
+ aauthMission: filters.aauthMission,
74
+ });
22
75
  }
23
76
 
24
77
  /**
25
78
  * Get request by frame number
26
79
  * @param {number} frameNumber - Frame number
27
- * @returns {Object|null} Packet object or null if not found
80
+ * @returns {Object|null} Packet object enriched with an `aauth` block, or null.
28
81
  */
29
82
  getRequest(frameNumber) {
30
83
  const parsedFrameNumber = Number.parseInt(frameNumber);
31
- return this.packetRepository.getByFrameNumber(parsedFrameNumber);
84
+ return enrichWithAauth(this.packetRepository.getByFrameNumber(parsedFrameNumber));
32
85
  }
33
86
 
34
87
  /**
@@ -51,6 +104,6 @@ export class RequestService {
51
104
  offset: Defaults.DEFAULT_OFFSET,
52
105
  });
53
106
  const repoFilters = exportFilters.toRepositoryFilters();
54
- return this.packetRepository.queryRequests(repoFilters);
107
+ return this.packetRepository.queryRequests(repoFilters).map(enrichWithAauth);
55
108
  }
56
109
  }
@@ -1,6 +1,17 @@
1
+ import path from 'node:path';
2
+
1
3
  import { Defaults } from '#core/constants/Defaults.js';
2
4
  import { initAuditLogger, startMcpSharkServer } from '#core/mcp-server/index.js';
3
5
 
6
+ function isSamePath(a, b) {
7
+ if (!a || !b) return false;
8
+ try {
9
+ return path.resolve(a) === path.resolve(b);
10
+ } catch {
11
+ return a === b;
12
+ }
13
+ }
14
+
4
15
  /**
5
16
  * Service for managing MCP Shark server lifecycle
6
17
  * Handles server startup, shutdown, and status
@@ -60,8 +71,34 @@ export class ServerManagementService {
60
71
  const { fileData, convertedConfig, updatedConfig } = setupResult;
61
72
  const mcpsJsonPath = this.configService.getMcpConfigPath();
62
73
 
63
- // Write converted config to MCP Shark config path
64
- this.configService.writeConfigAsJson(mcpsJsonPath, convertedConfig);
74
+ // The user can legitimately point the Setup panel at the proxy's own
75
+ // config file (~/.mcp-shark/mcps.json) when they just want to (re)start
76
+ // the proxy with whatever is already there — for example after
77
+ // `npm run testbed:up` has written upstreams directly. In that case the
78
+ // input *is* the output and the "patch the editor config" step at the
79
+ // end would rewrite every upstream URL into the proxy-local form
80
+ // (http://localhost:9851/mcp/<name>), turning mcps.json into a
81
+ // self-referential file that the next startup has to strip.
82
+ const sourceIsProxyConfig = isSamePath(fileData.resolvedFilePath, mcpsJsonPath);
83
+
84
+ // If the conversion produced zero upstreams (e.g. the user picked an
85
+ // already-patched ~/.cursor/mcp.json that only contains the mcp-shark
86
+ // self-reference, and no backup was available to restore), don't blow
87
+ // away whatever the proxy already has. A healthy existing mcps.json is
88
+ // strictly more useful than an empty new one.
89
+ const convertedUpstreamCount = Object.keys(convertedConfig?.servers || {}).length;
90
+ const existingHasUpstreams = this._existingMcpsHasUpstreams(mcpsJsonPath);
91
+ const skipWriteToPreserveExisting =
92
+ convertedUpstreamCount === 0 && existingHasUpstreams && !sourceIsProxyConfig;
93
+
94
+ if (skipWriteToPreserveExisting) {
95
+ this.logger?.warn(
96
+ { path: mcpsJsonPath, source: fileData.resolvedFilePath },
97
+ 'Conversion yielded zero upstreams; preserving existing mcps.json instead of overwriting'
98
+ );
99
+ } else {
100
+ this.configService.writeConfigAsJson(mcpsJsonPath, convertedConfig);
101
+ }
65
102
 
66
103
  // Stop existing server if running
67
104
  if (this.serverInstance?.stop) {
@@ -86,9 +123,13 @@ export class ServerManagementService {
86
123
  },
87
124
  });
88
125
 
89
- // Patch the original config file if it exists
126
+ // Patch the original config file if it exists. Skip this when the
127
+ // source IS our own proxy config — patching it would corrupt the
128
+ // upstream list we just wrote.
90
129
  const patchWarning =
91
- fileData.resolvedFilePath && this.configService.fileExists(fileData.resolvedFilePath)
130
+ fileData.resolvedFilePath &&
131
+ this.configService.fileExists(fileData.resolvedFilePath) &&
132
+ !sourceIsProxyConfig
92
133
  ? this.configPatchingService.patchConfigFile(fileData.resolvedFilePath, updatedConfig)
93
134
  .warning || null
94
135
  : null;
@@ -104,6 +145,20 @@ export class ServerManagementService {
104
145
  };
105
146
  }
106
147
 
148
+ _existingMcpsHasUpstreams(mcpsJsonPath) {
149
+ try {
150
+ if (!this.configService.fileExists(mcpsJsonPath)) return false;
151
+ const content = this.configService.readConfigFile(mcpsJsonPath);
152
+ const parsed = this.configService.tryParseJson(content, mcpsJsonPath);
153
+ if (!parsed) return false;
154
+ const count =
155
+ Object.keys(parsed.servers || {}).length + Object.keys(parsed.mcpServers || {}).length;
156
+ return count > 0;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
107
162
  /**
108
163
  * Start MCP Shark server
109
164
  */
@@ -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
+ }