@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.
- package/README.md +230 -0
- package/build/config.d.ts +45 -0
- package/build/config.js +177 -0
- package/build/http-transport.d.ts +16 -0
- package/build/http-transport.js +191 -0
- package/build/index.d.ts +16 -0
- package/build/index.js +31 -0
- package/build/server.d.ts +41 -0
- package/build/server.js +902 -0
- package/build/shared/errors.d.ts +50 -0
- package/build/shared/errors.js +69 -0
- package/build/shared/linkBuilder.d.ts +93 -0
- package/build/shared/linkBuilder.js +148 -0
- package/build/shared/logger.d.ts +10 -0
- package/build/shared/logger.js +28 -0
- package/build/shield/bootRole.d.ts +60 -0
- package/build/shield/bootRole.js +145 -0
- package/build/shield/client.d.ts +265 -0
- package/build/shield/client.js +656 -0
- package/build/shield/deploy/index.d.ts +69 -0
- package/build/shield/deploy/index.js +569 -0
- package/build/shield/discovery/dataStoreDetector.d.ts +3 -0
- package/build/shield/discovery/dataStoreDetector.js +125 -0
- package/build/shield/discovery/dockerScanner.d.ts +34 -0
- package/build/shield/discovery/dockerScanner.js +543 -0
- package/build/shield/discovery/endpointScanner.d.ts +3 -0
- package/build/shield/discovery/endpointScanner.js +306 -0
- package/build/shield/discovery/environmentScanner.d.ts +86 -0
- package/build/shield/discovery/environmentScanner.js +545 -0
- package/build/shield/discovery/externalServiceDetector.d.ts +3 -0
- package/build/shield/discovery/externalServiceDetector.js +98 -0
- package/build/shield/discovery/frameworkDetector.d.ts +3 -0
- package/build/shield/discovery/frameworkDetector.js +114 -0
- package/build/shield/discovery/manifestGenerator.d.ts +12 -0
- package/build/shield/discovery/manifestGenerator.js +124 -0
- package/build/shield/discovery/piiDetector.d.ts +5 -0
- package/build/shield/discovery/piiDetector.js +203 -0
- package/build/shield/discovery/severity.d.ts +47 -0
- package/build/shield/discovery/severity.js +138 -0
- package/build/shield/discovery/topologyNormalizer.d.ts +109 -0
- package/build/shield/discovery/topologyNormalizer.js +416 -0
- package/build/shield/identity.d.ts +53 -0
- package/build/shield/identity.js +70 -0
- package/build/shield/install/configMerge.d.ts +91 -0
- package/build/shield/install/configMerge.js +324 -0
- package/build/shield/install/keystore.d.ts +25 -0
- package/build/shield/install/keystore.js +156 -0
- package/build/shield/install/orchestrator.d.ts +33 -0
- package/build/shield/install/orchestrator.js +404 -0
- package/build/shield/install/transports/awsSsm.d.ts +43 -0
- package/build/shield/install/transports/awsSsm.js +378 -0
- package/build/shield/install/transports/bootstrapToken.d.ts +39 -0
- package/build/shield/install/transports/bootstrapToken.js +117 -0
- package/build/shield/install/transports/ssh.d.ts +50 -0
- package/build/shield/install/transports/ssh.js +569 -0
- package/build/shield/install/types.d.ts +139 -0
- package/build/shield/install/types.js +10 -0
- package/build/shield/protocol-walkthrough.d.ts +65 -0
- package/build/shield/protocol-walkthrough.js +392 -0
- package/build/shield/provision/appProvisioner.d.ts +15 -0
- package/build/shield/provision/appProvisioner.js +25 -0
- package/build/shield/types.d.ts +261 -0
- package/build/shield/types.js +4 -0
- package/build/shield/verify/postureReporter.d.ts +4 -0
- package/build/shield/verify/postureReporter.js +31 -0
- package/dxt/blacksands-ca.crt +67 -0
- package/dxt/scripts/setup.js +520 -0
- 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
|