@blacksandscyber/mcp-server-bursar 0.5.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 (68) hide show
  1. package/README.md +230 -0
  2. package/build/config.d.ts +45 -0
  3. package/build/config.js +177 -0
  4. package/build/http-transport.d.ts +16 -0
  5. package/build/http-transport.js +191 -0
  6. package/build/index.d.ts +16 -0
  7. package/build/index.js +31 -0
  8. package/build/server.d.ts +41 -0
  9. package/build/server.js +902 -0
  10. package/build/shared/errors.d.ts +50 -0
  11. package/build/shared/errors.js +69 -0
  12. package/build/shared/linkBuilder.d.ts +93 -0
  13. package/build/shared/linkBuilder.js +148 -0
  14. package/build/shared/logger.d.ts +10 -0
  15. package/build/shared/logger.js +28 -0
  16. package/build/shield/bootRole.d.ts +60 -0
  17. package/build/shield/bootRole.js +145 -0
  18. package/build/shield/client.d.ts +265 -0
  19. package/build/shield/client.js +656 -0
  20. package/build/shield/deploy/index.d.ts +69 -0
  21. package/build/shield/deploy/index.js +569 -0
  22. package/build/shield/discovery/dataStoreDetector.d.ts +3 -0
  23. package/build/shield/discovery/dataStoreDetector.js +125 -0
  24. package/build/shield/discovery/dockerScanner.d.ts +34 -0
  25. package/build/shield/discovery/dockerScanner.js +543 -0
  26. package/build/shield/discovery/endpointScanner.d.ts +3 -0
  27. package/build/shield/discovery/endpointScanner.js +306 -0
  28. package/build/shield/discovery/environmentScanner.d.ts +86 -0
  29. package/build/shield/discovery/environmentScanner.js +545 -0
  30. package/build/shield/discovery/externalServiceDetector.d.ts +3 -0
  31. package/build/shield/discovery/externalServiceDetector.js +98 -0
  32. package/build/shield/discovery/frameworkDetector.d.ts +3 -0
  33. package/build/shield/discovery/frameworkDetector.js +114 -0
  34. package/build/shield/discovery/manifestGenerator.d.ts +12 -0
  35. package/build/shield/discovery/manifestGenerator.js +124 -0
  36. package/build/shield/discovery/piiDetector.d.ts +5 -0
  37. package/build/shield/discovery/piiDetector.js +203 -0
  38. package/build/shield/discovery/severity.d.ts +47 -0
  39. package/build/shield/discovery/severity.js +138 -0
  40. package/build/shield/discovery/topologyNormalizer.d.ts +109 -0
  41. package/build/shield/discovery/topologyNormalizer.js +416 -0
  42. package/build/shield/identity.d.ts +53 -0
  43. package/build/shield/identity.js +70 -0
  44. package/build/shield/install/configMerge.d.ts +91 -0
  45. package/build/shield/install/configMerge.js +324 -0
  46. package/build/shield/install/keystore.d.ts +25 -0
  47. package/build/shield/install/keystore.js +156 -0
  48. package/build/shield/install/orchestrator.d.ts +33 -0
  49. package/build/shield/install/orchestrator.js +404 -0
  50. package/build/shield/install/transports/awsSsm.d.ts +43 -0
  51. package/build/shield/install/transports/awsSsm.js +378 -0
  52. package/build/shield/install/transports/bootstrapToken.d.ts +39 -0
  53. package/build/shield/install/transports/bootstrapToken.js +117 -0
  54. package/build/shield/install/transports/ssh.d.ts +50 -0
  55. package/build/shield/install/transports/ssh.js +569 -0
  56. package/build/shield/install/types.d.ts +139 -0
  57. package/build/shield/install/types.js +10 -0
  58. package/build/shield/protocol-walkthrough.d.ts +65 -0
  59. package/build/shield/protocol-walkthrough.js +392 -0
  60. package/build/shield/provision/appProvisioner.d.ts +15 -0
  61. package/build/shield/provision/appProvisioner.js +25 -0
  62. package/build/shield/types.d.ts +261 -0
  63. package/build/shield/types.js +4 -0
  64. package/build/shield/verify/postureReporter.d.ts +4 -0
  65. package/build/shield/verify/postureReporter.js +31 -0
  66. package/dxt/blacksands-ca.crt +67 -0
  67. package/dxt/scripts/setup.js +520 -0
  68. package/package.json +76 -0
@@ -0,0 +1,543 @@
1
+ "use strict";
2
+ /**
3
+ * Docker environment scanner — Phase 2, READ-ONLY Docker daemon inspection.
4
+ *
5
+ * Maps a local Docker environment into the SAME normalized {@link RawEnvironment}
6
+ * shape the macOS scanner produces, so {@link topologyNormalizer.buildInfraPlane}
7
+ * folds it into the frozen topology envelope through the identical code path.
8
+ * Docker supplies the INFRA plane only; the ZT plane is still built solely from
9
+ * the Shield Broker.
10
+ *
11
+ * SECURITY MODEL (hard requirements — mirrors environmentScanner.ts):
12
+ * - Only the `docker` binary is ever executed, and only with READ-ONLY
13
+ * subcommands (version/info/ps/inspect/network ls|inspect/volume ls|inspect/
14
+ * stats). No run/stop/rm/exec/build — enforced at type + runtime.
15
+ * - Every spawn uses execFile with an ARGUMENT ARRAY — never a shell string,
16
+ * never string-interpolation. Container/network/volume ids come from CLI
17
+ * output; any id beginning with "-" is rejected and option parsing is
18
+ * terminated with `--` so an id can never be read as a flag.
19
+ * - Every spawn has a timeout + maxBuffer. A missing binary, a failed
20
+ * `docker info` (daemon down), malformed output, or a timeout DEGRADES
21
+ * GRACEFULLY: we return whatever we have plus a human-readable note. The
22
+ * scan must never throw.
23
+ */
24
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ var desc = Object.getOwnPropertyDescriptor(m, k);
27
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
28
+ desc = { enumerable: true, get: function() { return m[k]; } };
29
+ }
30
+ Object.defineProperty(o, k2, desc);
31
+ }) : (function(o, m, k, k2) {
32
+ if (k2 === undefined) k2 = k;
33
+ o[k2] = m[k];
34
+ }));
35
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
36
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
37
+ }) : function(o, v) {
38
+ o["default"] = v;
39
+ });
40
+ var __importStar = (this && this.__importStar) || (function () {
41
+ var ownKeys = function(o) {
42
+ ownKeys = Object.getOwnPropertyNames || function (o) {
43
+ var ar = [];
44
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
45
+ return ar;
46
+ };
47
+ return ownKeys(o);
48
+ };
49
+ return function (mod) {
50
+ if (mod && mod.__esModule) return mod;
51
+ var result = {};
52
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
53
+ __setModuleDefault(result, mod);
54
+ return result;
55
+ };
56
+ })();
57
+ Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.scanDockerEnvironment = scanDockerEnvironment;
59
+ const node_child_process_1 = require("node:child_process");
60
+ const os = __importStar(require("node:os"));
61
+ const logger_1 = require("../../shared/logger");
62
+ // ── Binary + subcommand allowlist ────────────────────────────────────────────
63
+ // The `docker` CLI only, and only read-only verbs. The first arg of every
64
+ // invocation MUST be one of these; runAllowed asserts it at runtime.
65
+ const DOCKER_BINARY = "docker";
66
+ const ALLOWED_SUBCOMMANDS = new Set([
67
+ "version",
68
+ "info",
69
+ "ps",
70
+ "inspect",
71
+ "network",
72
+ "volume",
73
+ "stats",
74
+ ]);
75
+ // For the two-word subcommands (`network`/`volume`), only these verbs are allowed.
76
+ const ALLOWED_NETWORK_VERBS = new Set(["ls", "inspect"]);
77
+ const ALLOWED_VOLUME_VERBS = new Set(["ls", "inspect"]);
78
+ const EXEC_TIMEOUT_MS = 8_000;
79
+ const STATS_TIMEOUT_MS = 6_000; // `docker stats --no-stream` can be slow; keep it bounded.
80
+ const MAX_BUFFER = 8 * 1024 * 1024; // 8 MB
81
+ /**
82
+ * Run `docker <args…>` read-only. Enforces the binary + subcommand allowlist at
83
+ * runtime, uses an argument array (no shell), and never throws.
84
+ */
85
+ function runAllowed(args, timeoutMs = EXEC_TIMEOUT_MS) {
86
+ const sub = args[0];
87
+ if (!sub || !ALLOWED_SUBCOMMANDS.has(sub)) {
88
+ return Promise.resolve({ ok: false, stdout: "", stderr: "", error: `subcommand not allowlisted: ${sub ?? "<none>"}` });
89
+ }
90
+ if (sub === "network" && !ALLOWED_NETWORK_VERBS.has(args[1] ?? "")) {
91
+ return Promise.resolve({ ok: false, stdout: "", stderr: "", error: `network verb not allowlisted: ${args[1] ?? "<none>"}` });
92
+ }
93
+ if (sub === "volume" && !ALLOWED_VOLUME_VERBS.has(args[1] ?? "")) {
94
+ return Promise.resolve({ ok: false, stdout: "", stderr: "", error: `volume verb not allowlisted: ${args[1] ?? "<none>"}` });
95
+ }
96
+ return new Promise((resolve) => {
97
+ (0, node_child_process_1.execFile)(DOCKER_BINARY, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true }, (err, stdout, stderr) => {
98
+ if (err) {
99
+ const isMissing = err.code === "ENOENT";
100
+ resolve({
101
+ ok: false,
102
+ stdout: stdout ?? "",
103
+ stderr: stderr ?? "",
104
+ error: isMissing ? "missing" : err.message,
105
+ });
106
+ return;
107
+ }
108
+ resolve({ ok: true, stdout: stdout ?? "", stderr: stderr ?? "", error: null });
109
+ });
110
+ });
111
+ }
112
+ /** A container/network/volume id is safe to pass after `--` unless it starts with "-". */
113
+ function safeId(id) {
114
+ return !!id && !/^-/.test(id);
115
+ }
116
+ /** Parse one JSON object (single line). Returns null on failure. */
117
+ function parseJsonObject(text) {
118
+ const t = text.trim();
119
+ if (!t)
120
+ return null;
121
+ try {
122
+ const v = JSON.parse(t);
123
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ /** Parse NDJSON (one JSON object per line, as `--format '{{json .}}'` emits). */
130
+ function parseNdjson(text) {
131
+ const out = [];
132
+ for (const line of text.split("\n")) {
133
+ const t = line.trim();
134
+ if (!t)
135
+ continue;
136
+ try {
137
+ const v = JSON.parse(t);
138
+ if (v && typeof v === "object" && !Array.isArray(v))
139
+ out.push(v);
140
+ }
141
+ catch {
142
+ // skip malformed line; degrade gracefully
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+ function asRecord(v) {
148
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
149
+ }
150
+ function str(rec, ...keys) {
151
+ for (const k of keys) {
152
+ const v = rec[k];
153
+ if (typeof v === "string" && v.length)
154
+ return v;
155
+ if (typeof v === "number")
156
+ return String(v);
157
+ }
158
+ return null;
159
+ }
160
+ function num(rec, ...keys) {
161
+ for (const k of keys) {
162
+ const v = rec[k];
163
+ if (typeof v === "number" && Number.isFinite(v))
164
+ return v;
165
+ if (typeof v === "string" && v.trim() && Number.isFinite(Number(v)))
166
+ return Number(v);
167
+ }
168
+ return null;
169
+ }
170
+ /** Parse a "12.34%" string from `docker stats` into a number. Returns null on failure. */
171
+ function parsePercent(v) {
172
+ if (!v)
173
+ return undefined;
174
+ const n = Number(v.replace("%", "").trim());
175
+ return Number.isFinite(n) ? n : undefined;
176
+ }
177
+ // ── Docker status → frozen status mapping ─────────────────────────────────────
178
+ // The viz `dotFor()` set recognizes running/stopped (among others). Docker
179
+ // container State is one of: running, exited, dead, created, paused, restarting,
180
+ // removing. Per the contract: running→running; everything else→stopped.
181
+ function mapDockerState(state) {
182
+ const s = (state ?? "").toLowerCase();
183
+ if (s === "running" || s === "restarting")
184
+ return { status: "running", running: true };
185
+ // exited | dead | created | paused | removing | unknown → stopped
186
+ return { status: "stopped", running: false };
187
+ }
188
+ // ── Daemon/host facts ─────────────────────────────────────────────────────────
189
+ async function collectHostFacts(notes) {
190
+ // Pure-Node host facts (mirrors the Mac scanner's local section).
191
+ const cpus = os.cpus() ?? [];
192
+ const totalMemBytes = os.totalmem();
193
+ const ifaces = os.networkInterfaces();
194
+ const interfaces = [];
195
+ for (const [name, addrs] of Object.entries(ifaces)) {
196
+ for (const a of addrs ?? []) {
197
+ if (a.internal)
198
+ continue;
199
+ const fam = a.family;
200
+ interfaces.push({
201
+ name,
202
+ address: a.address,
203
+ family: typeof fam === "number" ? `IPv${fam}` : fam,
204
+ cidr: a.cidr ?? null,
205
+ });
206
+ }
207
+ }
208
+ // Engine facts via `docker version` + `docker info`. `docker info` failing is
209
+ // the canonical "daemon not reachable" signal.
210
+ let engineVersion = null;
211
+ let engineOs = null;
212
+ let engineArch = null;
213
+ let containersRunning = null;
214
+ let containersStopped = null;
215
+ let engineMemBytes = null;
216
+ let operatingSystem = null;
217
+ const ver = await runAllowed(["version", "--format", "{{json .}}"]);
218
+ if (ver.ok) {
219
+ const obj = parseJsonObject(ver.stdout);
220
+ const server = obj ? asRecord(obj["Server"]) : null;
221
+ if (server) {
222
+ engineVersion = str(server, "Version");
223
+ engineOs = str(server, "Os");
224
+ engineArch = str(server, "Arch");
225
+ }
226
+ }
227
+ const info = await runAllowed(["info", "--format", "{{json .}}"]);
228
+ let daemonUp = false;
229
+ if (info.ok) {
230
+ daemonUp = true;
231
+ const obj = parseJsonObject(info.stdout);
232
+ if (obj) {
233
+ containersRunning = num(obj, "ContainersRunning");
234
+ containersStopped = num(obj, "ContainersStopped");
235
+ engineMemBytes = num(obj, "MemTotal");
236
+ operatingSystem = str(obj, "OperatingSystem");
237
+ if (!engineArch)
238
+ engineArch = str(obj, "Architecture");
239
+ }
240
+ }
241
+ else if (info.error === "missing") {
242
+ notes.push("`docker` CLI not found — Docker infra discovery skipped");
243
+ }
244
+ else {
245
+ notes.push(`docker info failed (${info.error}) — Docker daemon unreachable; infra plane empty`);
246
+ }
247
+ const host = {
248
+ hostname: os.hostname(),
249
+ arch: os.arch(),
250
+ platform: os.platform(),
251
+ release: os.release(),
252
+ vcpu: cpus.length,
253
+ cpuModel: cpus[0]?.model?.trim() ?? "unknown",
254
+ totalMemBytes,
255
+ totalMemGB: `${Math.round(totalMemBytes / 1024 ** 3)} GB`,
256
+ interfaces,
257
+ containerCliPresent: daemonUp,
258
+ };
259
+ // Stash engine facts on the host record via the (open) RawHostFacts? No — keep
260
+ // RawHostFacts shape stable. The normalizer reads host fields it knows; engine
261
+ // detail is surfaced through notes instead to avoid contract drift.
262
+ if (daemonUp) {
263
+ const parts = [
264
+ engineVersion ? `engine ${engineVersion}` : null,
265
+ operatingSystem ?? (engineOs ? `${engineOs}/${engineArch ?? "?"}` : null),
266
+ containersRunning != null ? `${containersRunning} running` : null,
267
+ containersStopped != null ? `${containersStopped} stopped` : null,
268
+ engineMemBytes != null ? `${Math.round(engineMemBytes / 1024 ** 3)} GB engine mem` : null,
269
+ ].filter(Boolean);
270
+ if (parts.length)
271
+ notes.push(`Docker daemon: ${parts.join(", ")}`);
272
+ }
273
+ return { host, daemonUp };
274
+ }
275
+ // ── Containers ────────────────────────────────────────────────────────────────
276
+ async function listContainers(notes) {
277
+ // `-a` so stopped containers appear (the viz shows stopped nodes). NDJSON out.
278
+ const ps = await runAllowed(["ps", "-a", "--format", "{{json .}}"]);
279
+ if (!ps.ok) {
280
+ if (ps.error !== "missing")
281
+ notes.push(`docker ps -a failed (${ps.error}); no containers discovered`);
282
+ return [];
283
+ }
284
+ const rows = parseNdjson(ps.stdout);
285
+ const out = [];
286
+ for (const r of rows) {
287
+ const id = str(r, "ID", "Id") ?? "";
288
+ if (!id)
289
+ continue;
290
+ // ps `State` is the canonical lifecycle word (running/exited/created/paused…).
291
+ const { status, running } = mapDockerState(str(r, "State"));
292
+ // ps `Networks` is a comma-joined string; `Ports` is a comma-joined string.
293
+ const netStr = str(r, "Networks");
294
+ const portStr = str(r, "Ports");
295
+ out.push({
296
+ id,
297
+ name: str(r, "Names", "Name") ?? id,
298
+ image: str(r, "Image"),
299
+ status,
300
+ running,
301
+ ip: null, // ps doesn't carry the IP; inspect fills it.
302
+ networks: netStr ? netStr.split(",").map((s) => s.trim()).filter(Boolean) : [],
303
+ volumes: [], // inspect fills named volumes.
304
+ ports: portStr ? portStr.split(",").map((s) => s.trim()).filter(Boolean) : [],
305
+ command: str(r, "Command"),
306
+ });
307
+ }
308
+ await enrichContainers(out, notes);
309
+ return out;
310
+ }
311
+ /**
312
+ * Per-container enrichment via `docker inspect <id>`. Fills ip, networks (with
313
+ * subnet-bearing names), named volumes, command, image. Failures are silent
314
+ * per-container (we already have a node from `ps`).
315
+ */
316
+ async function enrichContainers(containers, notes) {
317
+ let failures = 0;
318
+ for (const c of containers) {
319
+ if (!safeId(c.id)) {
320
+ failures++;
321
+ continue;
322
+ }
323
+ const res = await runAllowed(["inspect", "--", c.id]);
324
+ if (!res.ok) {
325
+ failures++;
326
+ continue;
327
+ }
328
+ // `docker inspect` returns a JSON ARRAY of one object.
329
+ let parsed;
330
+ try {
331
+ parsed = JSON.parse(res.stdout.trim());
332
+ }
333
+ catch {
334
+ continue;
335
+ }
336
+ const rec = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed);
337
+ if (!rec)
338
+ continue;
339
+ const config = asRecord(rec["Config"]);
340
+ const netSettings = asRecord(rec["NetworkSettings"]);
341
+ const state = asRecord(rec["State"]);
342
+ if (state) {
343
+ const mapped = mapDockerState(str(state, "Status"));
344
+ c.status = mapped.status;
345
+ c.running = mapped.running;
346
+ }
347
+ if (!c.image && config)
348
+ c.image = str(config, "Image");
349
+ if (!c.command && config) {
350
+ const cmd = config["Cmd"];
351
+ if (Array.isArray(cmd))
352
+ c.command = cmd.filter((x) => typeof x === "string").join(" ") || c.command;
353
+ else
354
+ c.command = str(config, "Cmd") ?? c.command;
355
+ }
356
+ // NetworkSettings.Networks: { "<netName>": { IPAddress, NetworkID, … } }
357
+ const networks = netSettings ? asRecord(netSettings["Networks"]) : null;
358
+ if (networks) {
359
+ const names = Object.keys(networks);
360
+ if (names.length)
361
+ c.networks = names;
362
+ for (const n of names) {
363
+ const ns = asRecord(networks[n]);
364
+ const ip = ns ? str(ns, "IPAddress") : null;
365
+ if (ip && !c.ip)
366
+ c.ip = ip;
367
+ }
368
+ }
369
+ // Mounts: [{ Type:"volume", Name, Source, Destination }]. Only NAMED volumes
370
+ // (Type === "volume" with a Name) map to volume nodes; bind mounts are skipped.
371
+ const mounts = rec["Mounts"];
372
+ if (Array.isArray(mounts)) {
373
+ const vols = [];
374
+ for (const m of mounts) {
375
+ const mr = asRecord(m);
376
+ if (!mr)
377
+ continue;
378
+ if (str(mr, "Type") === "volume") {
379
+ const name = str(mr, "Name");
380
+ if (name)
381
+ vols.push(name);
382
+ }
383
+ }
384
+ if (vols.length)
385
+ c.volumes = vols;
386
+ }
387
+ }
388
+ if (failures && failures === containers.length) {
389
+ notes.push("docker inspect unavailable for all containers; node detail is partial");
390
+ }
391
+ }
392
+ // ── Networks ──────────────────────────────────────────────────────────────────
393
+ async function listNetworks(notes) {
394
+ const ls = await runAllowed(["network", "ls", "--format", "{{json .}}"]);
395
+ if (!ls.ok) {
396
+ if (ls.error !== "missing")
397
+ notes.push(`docker network ls failed (${ls.error})`);
398
+ return [];
399
+ }
400
+ const rows = parseNdjson(ls.stdout);
401
+ const out = [];
402
+ for (const r of rows) {
403
+ const name = str(r, "Name");
404
+ const id = str(r, "ID", "Id");
405
+ if (!name && !id)
406
+ continue;
407
+ out.push({
408
+ id: id ?? name,
409
+ name: name ?? id,
410
+ subnet: null,
411
+ gateway: null,
412
+ driver: str(r, "Driver"),
413
+ });
414
+ }
415
+ // Optional enrichment: subnet/gateway via `docker network inspect`.
416
+ for (const net of out) {
417
+ if (!safeId(net.id))
418
+ continue;
419
+ const res = await runAllowed(["network", "inspect", "--", net.id]);
420
+ if (!res.ok)
421
+ continue;
422
+ let parsed;
423
+ try {
424
+ parsed = JSON.parse(res.stdout.trim());
425
+ }
426
+ catch {
427
+ continue;
428
+ }
429
+ const rec = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed);
430
+ if (!rec)
431
+ continue;
432
+ const ipam = asRecord(rec["IPAM"]);
433
+ const cfg = ipam ? ipam["Config"] : null;
434
+ if (Array.isArray(cfg) && cfg.length) {
435
+ const first = asRecord(cfg[0]);
436
+ if (first) {
437
+ net.subnet = str(first, "Subnet");
438
+ net.gateway = str(first, "Gateway");
439
+ }
440
+ }
441
+ }
442
+ return out;
443
+ }
444
+ // ── Volumes ───────────────────────────────────────────────────────────────────
445
+ async function listVolumes(notes) {
446
+ const ls = await runAllowed(["volume", "ls", "--format", "{{json .}}"]);
447
+ if (!ls.ok) {
448
+ if (ls.error !== "missing")
449
+ notes.push(`docker volume ls failed (${ls.error})`);
450
+ return [];
451
+ }
452
+ const rows = parseNdjson(ls.stdout);
453
+ const out = [];
454
+ for (const r of rows) {
455
+ const name = str(r, "Name");
456
+ if (!name)
457
+ continue;
458
+ out.push({
459
+ id: name,
460
+ name,
461
+ driver: str(r, "Driver"),
462
+ source: str(r, "Mountpoint", "Source"),
463
+ });
464
+ }
465
+ return out;
466
+ }
467
+ // ── Live metrics (best-effort) ────────────────────────────────────────────────
468
+ async function attachStats(containers, notes) {
469
+ const running = containers.filter((c) => c.running);
470
+ if (!running.length)
471
+ return;
472
+ const res = await runAllowed(["stats", "--no-stream", "--format", "{{json .}}"], STATS_TIMEOUT_MS);
473
+ if (!res.ok) {
474
+ notes.push(res.error === "missing"
475
+ ? "docker stats unavailable — live cpu/mem metrics skipped"
476
+ : `docker stats failed (${res.error}) — live cpu/mem metrics skipped`);
477
+ return;
478
+ }
479
+ const rows = parseNdjson(res.stdout);
480
+ // Index stats by both full id and short id (and name) for robust matching.
481
+ const byKey = new Map();
482
+ for (const r of rows) {
483
+ const sid = str(r, "ID", "Container");
484
+ const name = str(r, "Name");
485
+ if (sid)
486
+ byKey.set(sid, r);
487
+ if (name)
488
+ byKey.set(name, r);
489
+ }
490
+ for (const c of running) {
491
+ const row = byKey.get(c.id) ??
492
+ byKey.get(c.id.slice(0, 12)) ??
493
+ byKey.get(c.name) ??
494
+ // stats short id vs our (possibly full) id: match by prefix.
495
+ [...byKey.entries()].find(([k]) => c.id.startsWith(k) || k.startsWith(c.id))?.[1];
496
+ if (!row)
497
+ continue;
498
+ const cpu = parsePercent(str(row, "CPUPerc"));
499
+ const mem = parsePercent(str(row, "MemPerc"));
500
+ if (cpu !== undefined)
501
+ c.cpu = cpu;
502
+ if (mem !== undefined)
503
+ c.mem = mem;
504
+ }
505
+ }
506
+ // ── Public entry point ────────────────────────────────────────────────────────
507
+ /**
508
+ * Inspect the local Docker environment READ-ONLY and return the raw environment
509
+ * in the SAME shape as {@link scanMacEnvironment}. Never throws: any failure is
510
+ * captured in `notes` and the corresponding section is returned empty/partial.
511
+ *
512
+ * `listeningPorts` is left empty for the Docker provider — published container
513
+ * ports are already surfaced per-container; host-level TCP LISTEN inspection is
514
+ * the macOS scanner's concern.
515
+ */
516
+ async function scanDockerEnvironment() {
517
+ const notes = [];
518
+ const { host, daemonUp } = await collectHostFacts(notes);
519
+ // If the daemon is unreachable, return an empty-but-valid environment (host
520
+ // facts only) with a clear note — never throw.
521
+ if (!daemonUp) {
522
+ return { host, networks: [], containers: [], volumes: [], listeningPorts: [], notes };
523
+ }
524
+ let containers = [];
525
+ let networks = [];
526
+ let volumes = [];
527
+ try {
528
+ [containers, networks, volumes] = await Promise.all([
529
+ listContainers(notes),
530
+ listNetworks(notes),
531
+ listVolumes(notes),
532
+ ]);
533
+ // Stats after we know which containers are running (best-effort, non-blocking
534
+ // on failure).
535
+ await attachStats(containers, notes);
536
+ }
537
+ catch (err) {
538
+ logger_1.logger.warn("scanDockerEnvironment: unexpected error during inspection", { err: String(err) });
539
+ notes.push(`Docker environment scan partially failed: ${String(err)}`);
540
+ }
541
+ return { host, networks, containers, volumes, listeningPorts: [], notes };
542
+ }
543
+ //# sourceMappingURL=dockerScanner.js.map
@@ -0,0 +1,3 @@
1
+ import type { EndpointInfo, FrameworkInfo } from "../types";
2
+ export declare function scanEndpoints(projectPath: string, frameworkInfo: FrameworkInfo): Promise<EndpointInfo[]>;
3
+ //# sourceMappingURL=endpointScanner.d.ts.map