mcp-aws-manager 0.1.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/LICENSE +21 -0
- package/MCP_CLIENT_SETUP.md +78 -0
- package/README.md +140 -0
- package/bin/mcp-aws-manager-mcp.js +550 -0
- package/bin/mcp-aws-manager.js +1160 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const net = require("node:net");
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SSH_USERNAMES = ["ec2-user", "ubuntu", "admin", "root"];
|
|
11
|
+
const TOTAL_STEPS = 9;
|
|
12
|
+
|
|
13
|
+
const PEM_BODY_RE =
|
|
14
|
+
/-----BEGIN [^-]+-----\s+([A-Za-z0-9+/=\r\n]+)-----END [^-]+-----/m;
|
|
15
|
+
const BASE64_RE = /^[A-Za-z0-9+/=]+$/;
|
|
16
|
+
const HEXISH_RE = /^[0-9a-fA-F:]+$/;
|
|
17
|
+
|
|
18
|
+
function eprint(message) {
|
|
19
|
+
process.stderr.write(String(message) + "\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function progress(config, step, title) {
|
|
23
|
+
if (config.noProgress) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
eprint(`[${step}/${TOTAL_STEPS}] ${title}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function envText(name) {
|
|
30
|
+
const value = process.env[name];
|
|
31
|
+
if (value == null) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const trimmed = String(value).trim();
|
|
35
|
+
return trimmed || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envBool(name) {
|
|
39
|
+
const value = envText(name);
|
|
40
|
+
if (!value) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseCsvList(raw) {
|
|
47
|
+
if (!raw) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const items = String(raw)
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((v) => v.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
return items.length ? items : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function listPemFilesInDirectory(directory) {
|
|
58
|
+
let entries = [];
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return entries
|
|
66
|
+
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".pem")
|
|
67
|
+
.map((entry) => path.join(directory, entry.name))
|
|
68
|
+
.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function usageText() {
|
|
72
|
+
return [
|
|
73
|
+
"Usage: mcp-aws-manager [options]",
|
|
74
|
+
"",
|
|
75
|
+
"Discover AWS EC2 instances with public IPv4 addresses and match them against",
|
|
76
|
+
"a PEM key fingerprint. Optionally test SSH connectivity.",
|
|
77
|
+
"",
|
|
78
|
+
"Options:",
|
|
79
|
+
" --pem-path <path[,..]> Path to PEM private key file(s), comma-separated",
|
|
80
|
+
" --profiles <a,b,c> AWS profile names; default: all local profiles or default chain",
|
|
81
|
+
" --regions <a,b,c> AWS regions; default: DescribeRegions per profile",
|
|
82
|
+
" --format <json|csv> Output format (default: json)",
|
|
83
|
+
" --out <path> Write output to file instead of stdout",
|
|
84
|
+
" --key-name <name> Fallback EC2 KeyPair name for matching",
|
|
85
|
+
" --ssh-check Attempt SSH authentication using PEM key(s) (optional)",
|
|
86
|
+
" --ssh-usernames <a,b,c> SSH usernames to try (default: ec2-user,ubuntu,admin,root)",
|
|
87
|
+
" --ssh-timeout <seconds> SSH timeout (default: 5)",
|
|
88
|
+
" --matched-only Emit only matched instances",
|
|
89
|
+
" --progress Show step progress logs on stderr (default: off)",
|
|
90
|
+
" --no-progress Suppress step progress logs on stderr (default)",
|
|
91
|
+
" -h, --help Show this help",
|
|
92
|
+
"",
|
|
93
|
+
"PEM path resolution order:",
|
|
94
|
+
" 1) --pem-path",
|
|
95
|
+
" 2) MCP_AWS_PEM_PATH",
|
|
96
|
+
" 3) all *.pem files in current working directory",
|
|
97
|
+
"",
|
|
98
|
+
"Environment variable fallbacks:",
|
|
99
|
+
" MCP_AWS_PEM_PATH, MCP_AWS_PROFILES, MCP_AWS_REGIONS, MCP_AWS_FORMAT,",
|
|
100
|
+
" MCP_AWS_OUT, MCP_AWS_KEY_NAME, MCP_AWS_SSH_CHECK, MCP_AWS_SSH_USERS,",
|
|
101
|
+
" MCP_AWS_SSH_TIMEOUT, MCP_AWS_MATCHED_ONLY, MCP_AWS_NO_PROGRESS",
|
|
102
|
+
""
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
const options = {
|
|
108
|
+
pemPath: null,
|
|
109
|
+
profiles: null,
|
|
110
|
+
regions: null,
|
|
111
|
+
format: null,
|
|
112
|
+
outPath: null,
|
|
113
|
+
keyName: null,
|
|
114
|
+
sshCheck: false,
|
|
115
|
+
sshUsernames: null,
|
|
116
|
+
sshTimeout: null,
|
|
117
|
+
matchedOnly: false,
|
|
118
|
+
noProgress: null,
|
|
119
|
+
help: false
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const args = Array.from(argv);
|
|
123
|
+
|
|
124
|
+
function setOption(key, value) {
|
|
125
|
+
switch (key) {
|
|
126
|
+
case "pem-path":
|
|
127
|
+
options.pemPath = value;
|
|
128
|
+
break;
|
|
129
|
+
case "profiles":
|
|
130
|
+
options.profiles = value;
|
|
131
|
+
break;
|
|
132
|
+
case "regions":
|
|
133
|
+
options.regions = value;
|
|
134
|
+
break;
|
|
135
|
+
case "format":
|
|
136
|
+
options.format = value;
|
|
137
|
+
break;
|
|
138
|
+
case "out":
|
|
139
|
+
options.outPath = value;
|
|
140
|
+
break;
|
|
141
|
+
case "key-name":
|
|
142
|
+
options.keyName = value;
|
|
143
|
+
break;
|
|
144
|
+
case "ssh-usernames":
|
|
145
|
+
options.sshUsernames = value;
|
|
146
|
+
break;
|
|
147
|
+
case "ssh-timeout":
|
|
148
|
+
options.sshTimeout = value;
|
|
149
|
+
break;
|
|
150
|
+
default:
|
|
151
|
+
throw new Error(`Unknown option --${key}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
156
|
+
const arg = args[i];
|
|
157
|
+
if (arg === "-h" || arg === "--help") {
|
|
158
|
+
options.help = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (arg === "--ssh-check") {
|
|
162
|
+
options.sshCheck = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (arg === "--matched-only") {
|
|
166
|
+
options.matchedOnly = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (arg === "--no-progress") {
|
|
170
|
+
options.noProgress = true;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (arg === "--progress") {
|
|
174
|
+
options.noProgress = false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!arg.startsWith("--")) {
|
|
178
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const eqIndex = arg.indexOf("=");
|
|
182
|
+
if (eqIndex !== -1) {
|
|
183
|
+
const key = arg.slice(2, eqIndex);
|
|
184
|
+
const value = arg.slice(eqIndex + 1);
|
|
185
|
+
if (!value) {
|
|
186
|
+
throw new Error(`Missing value for --${key}`);
|
|
187
|
+
}
|
|
188
|
+
setOption(key, value);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const key = arg.slice(2);
|
|
193
|
+
const next = args[i + 1];
|
|
194
|
+
if (!next || next.startsWith("--")) {
|
|
195
|
+
throw new Error(`Missing value for --${key}`);
|
|
196
|
+
}
|
|
197
|
+
setOption(key, next);
|
|
198
|
+
i += 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (options.help) {
|
|
202
|
+
return { help: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const argPemPaths = parseCsvList(options.pemPath);
|
|
206
|
+
const envPemPaths = parseCsvList(envText("MCP_AWS_PEM_PATH"));
|
|
207
|
+
let pemPaths = argPemPaths || envPemPaths;
|
|
208
|
+
let pemPathSource = argPemPaths ? "arg" : envPemPaths ? "env" : "cwd-auto";
|
|
209
|
+
let autoPemCandidates = [];
|
|
210
|
+
|
|
211
|
+
if (!pemPaths) {
|
|
212
|
+
autoPemCandidates = listPemFilesInDirectory(process.cwd());
|
|
213
|
+
if (!autoPemCandidates.length) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"No PEM path provided. Use --pem-path, set MCP_AWS_PEM_PATH, or place .pem files in the current working directory."
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
pemPaths = autoPemCandidates;
|
|
219
|
+
}
|
|
220
|
+
const resolvedPemPaths = resolveUniquePemPaths(pemPaths);
|
|
221
|
+
if (!resolvedPemPaths.length) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"No usable PEM path provided. Use --pem-path, set MCP_AWS_PEM_PATH, or place .pem files in the current working directory."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const format = (options.format || envText("MCP_AWS_FORMAT") || "json").toLowerCase();
|
|
228
|
+
if (!["json", "csv"].includes(format)) {
|
|
229
|
+
throw new Error("--format must be one of: json, csv");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let sshTimeout = options.sshTimeout;
|
|
233
|
+
if (sshTimeout == null) {
|
|
234
|
+
sshTimeout = envText("MCP_AWS_SSH_TIMEOUT") || "5";
|
|
235
|
+
}
|
|
236
|
+
const sshTimeoutNumber = Number(sshTimeout);
|
|
237
|
+
if (!Number.isFinite(sshTimeoutNumber) || sshTimeoutNumber <= 0) {
|
|
238
|
+
throw new Error("--ssh-timeout must be a positive number");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sshUsernames =
|
|
242
|
+
parseCsvList(options.sshUsernames) ||
|
|
243
|
+
parseCsvList(envText("MCP_AWS_SSH_USERS")) ||
|
|
244
|
+
DEFAULT_SSH_USERNAMES.slice();
|
|
245
|
+
|
|
246
|
+
const envNoProgressText = envText("MCP_AWS_NO_PROGRESS");
|
|
247
|
+
const noProgress =
|
|
248
|
+
options.noProgress != null
|
|
249
|
+
? options.noProgress
|
|
250
|
+
: envNoProgressText != null
|
|
251
|
+
? envBool("MCP_AWS_NO_PROGRESS")
|
|
252
|
+
: true;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
help: false,
|
|
256
|
+
pemPaths: resolvedPemPaths,
|
|
257
|
+
pemPath: resolvedPemPaths[0],
|
|
258
|
+
pemPathSource,
|
|
259
|
+
autoPemCandidates,
|
|
260
|
+
profiles:
|
|
261
|
+
parseCsvList(options.profiles) || parseCsvList(envText("MCP_AWS_PROFILES")),
|
|
262
|
+
regions: parseCsvList(options.regions) || parseCsvList(envText("MCP_AWS_REGIONS")),
|
|
263
|
+
format,
|
|
264
|
+
outPath: options.outPath ? path.resolve(expandHome(options.outPath)) : (envText("MCP_AWS_OUT") ? path.resolve(expandHome(envText("MCP_AWS_OUT"))) : null),
|
|
265
|
+
keyName: options.keyName || envText("MCP_AWS_KEY_NAME"),
|
|
266
|
+
sshCheck: Boolean(options.sshCheck || envBool("MCP_AWS_SSH_CHECK")),
|
|
267
|
+
sshUsernames,
|
|
268
|
+
sshTimeoutMs: Math.round(sshTimeoutNumber * 1000),
|
|
269
|
+
matchedOnly: Boolean(options.matchedOnly || envBool("MCP_AWS_MATCHED_ONLY")),
|
|
270
|
+
noProgress
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function expandHome(value) {
|
|
275
|
+
if (!value.startsWith("~")) {
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
const home = os.homedir();
|
|
279
|
+
if (value === "~") {
|
|
280
|
+
return home;
|
|
281
|
+
}
|
|
282
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
283
|
+
return path.join(home, value.slice(2));
|
|
284
|
+
}
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function uniquePathKey(pemPath) {
|
|
289
|
+
return process.platform === "win32" ? pemPath.toLowerCase() : pemPath;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function resolveUniquePemPaths(values) {
|
|
293
|
+
const input = Array.isArray(values) ? values : [];
|
|
294
|
+
const resolved = [];
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
for (const value of input) {
|
|
297
|
+
const text = String(value || "").trim();
|
|
298
|
+
if (!text) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const fullPath = path.resolve(expandHome(text));
|
|
302
|
+
const key = uniquePathKey(fullPath);
|
|
303
|
+
if (seen.has(key)) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
seen.add(key);
|
|
307
|
+
resolved.push(fullPath);
|
|
308
|
+
}
|
|
309
|
+
return resolved;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function validateConfig(config) {
|
|
313
|
+
const warnings = [];
|
|
314
|
+
|
|
315
|
+
if (config.pemPathSource === "cwd-auto") {
|
|
316
|
+
if (Array.isArray(config.autoPemCandidates) && config.autoPemCandidates.length > 1) {
|
|
317
|
+
warnings.push(
|
|
318
|
+
`Auto-selected ${config.autoPemCandidates.length} PEM files from current directory.`
|
|
319
|
+
);
|
|
320
|
+
} else if (config.pemPaths.length === 1) {
|
|
321
|
+
warnings.push(`Auto-selected PEM from current directory: ${config.pemPaths[0]}`);
|
|
322
|
+
} else {
|
|
323
|
+
warnings.push(
|
|
324
|
+
`Auto-selected PEM files from current directory: ${config.pemPaths.join(", ")}`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!Array.isArray(config.pemPaths) || !config.pemPaths.length) {
|
|
330
|
+
throw new Error("No PEM file paths were resolved.");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const pemPath of config.pemPaths) {
|
|
334
|
+
if (!fs.existsSync(pemPath)) {
|
|
335
|
+
throw new Error(`PEM file not found: ${pemPath}`);
|
|
336
|
+
}
|
|
337
|
+
const stat = fs.statSync(pemPath);
|
|
338
|
+
if (!stat.isFile()) {
|
|
339
|
+
throw new Error(`PEM path is not a file: ${pemPath}`);
|
|
340
|
+
}
|
|
341
|
+
if (path.extname(pemPath).toLowerCase() !== ".pem") {
|
|
342
|
+
warnings.push(`PEM path does not end with .pem: ${path.basename(pemPath)}`);
|
|
343
|
+
}
|
|
344
|
+
if (process.platform !== "win32") {
|
|
345
|
+
const mode = stat.mode & 0o777;
|
|
346
|
+
if ((mode & 0o077) !== 0) {
|
|
347
|
+
warnings.push(
|
|
348
|
+
`PEM file ${path.basename(pemPath)} is readable by group/others. Consider chmod 600.`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (config.outPath) {
|
|
355
|
+
fs.mkdirSync(path.dirname(config.outPath), { recursive: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (config.sshCheck) {
|
|
359
|
+
try {
|
|
360
|
+
require("ssh2");
|
|
361
|
+
} catch (error) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
"--ssh-check requires optional dependency 'ssh2'. Run: npm install ssh2"
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return warnings;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function bufferFromFirstPemDer(pemText) {
|
|
372
|
+
const match = PEM_BODY_RE.exec(pemText);
|
|
373
|
+
if (!match) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const normalized = match[1].replace(/\s+/g, "");
|
|
377
|
+
try {
|
|
378
|
+
return Buffer.from(normalized, "base64");
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function colonHex(hex) {
|
|
385
|
+
return hex.match(/.{1,2}/g)?.join(":") || hex;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function digestVariants(buffer) {
|
|
389
|
+
const md5Hex = crypto.createHash("md5").update(buffer).digest("hex");
|
|
390
|
+
const sha1Hex = crypto.createHash("sha1").update(buffer).digest("hex");
|
|
391
|
+
const sha256B64 = crypto
|
|
392
|
+
.createHash("sha256")
|
|
393
|
+
.update(buffer)
|
|
394
|
+
.digest("base64")
|
|
395
|
+
.replace(/=+$/g, "");
|
|
396
|
+
return [
|
|
397
|
+
md5Hex,
|
|
398
|
+
colonHex(md5Hex),
|
|
399
|
+
sha1Hex,
|
|
400
|
+
colonHex(sha1Hex),
|
|
401
|
+
sha256B64,
|
|
402
|
+
`SHA256:${sha256B64}`
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function fingerprintMatchKeys(value) {
|
|
407
|
+
const raw = String(value || "").trim();
|
|
408
|
+
const keys = new Set();
|
|
409
|
+
if (!raw) {
|
|
410
|
+
return keys;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
keys.add(`raw:${raw}`);
|
|
414
|
+
keys.add(`raw:${raw.toLowerCase()}`);
|
|
415
|
+
|
|
416
|
+
if (raw.toLowerCase().startsWith("sha256:")) {
|
|
417
|
+
keys.add(`sha256:${raw.slice(raw.indexOf(":") + 1).replace(/=+$/g, "")}`);
|
|
418
|
+
} else if (BASE64_RE.test(raw) && !HEXISH_RE.test(raw)) {
|
|
419
|
+
keys.add(`sha256:${raw.replace(/=+$/g, "")}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (HEXISH_RE.test(raw)) {
|
|
423
|
+
const compact = raw.replace(/:/g, "").toLowerCase();
|
|
424
|
+
if (compact.length % 2 === 0) {
|
|
425
|
+
keys.add(`hex:${compact}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return keys;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function analyzePemKey(pemPath) {
|
|
433
|
+
const pemText = fs.readFileSync(pemPath, "utf8");
|
|
434
|
+
const pemRawBuffer = fs.readFileSync(pemPath);
|
|
435
|
+
const rawDer = bufferFromFirstPemDer(pemText);
|
|
436
|
+
|
|
437
|
+
let privateKey;
|
|
438
|
+
try {
|
|
439
|
+
privateKey = crypto.createPrivateKey({ key: pemRawBuffer });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
throw new Error(`Failed to load PEM private key: ${error.message}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const publicKey = crypto.createPublicKey(privateKey);
|
|
445
|
+
const keyType = privateKey.asymmetricKeyType || "unknown";
|
|
446
|
+
|
|
447
|
+
const candidates = [];
|
|
448
|
+
const matchKeys = new Set();
|
|
449
|
+
|
|
450
|
+
function addCandidateDigests(buffer) {
|
|
451
|
+
if (!buffer || !Buffer.isBuffer(buffer)) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
for (const candidate of digestVariants(buffer)) {
|
|
455
|
+
candidates.push(candidate);
|
|
456
|
+
for (const k of fingerprintMatchKeys(candidate)) {
|
|
457
|
+
matchKeys.add(k);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
addCandidateDigests(rawDer);
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
addCandidateDigests(
|
|
466
|
+
privateKey.export({ format: "der", type: "pkcs8" })
|
|
467
|
+
);
|
|
468
|
+
} catch {}
|
|
469
|
+
|
|
470
|
+
if (keyType === "rsa") {
|
|
471
|
+
try {
|
|
472
|
+
addCandidateDigests(
|
|
473
|
+
privateKey.export({ format: "der", type: "pkcs1" })
|
|
474
|
+
);
|
|
475
|
+
} catch {}
|
|
476
|
+
}
|
|
477
|
+
if (keyType === "ec") {
|
|
478
|
+
try {
|
|
479
|
+
addCandidateDigests(
|
|
480
|
+
privateKey.export({ format: "der", type: "sec1" })
|
|
481
|
+
);
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
addCandidateDigests(publicKey.export({ format: "der", type: "spki" }));
|
|
487
|
+
} catch {}
|
|
488
|
+
|
|
489
|
+
const uniqueCandidates = [];
|
|
490
|
+
const seen = new Set();
|
|
491
|
+
for (const c of candidates) {
|
|
492
|
+
if (seen.has(c)) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
seen.add(c);
|
|
496
|
+
uniqueCandidates.push(c);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!uniqueCandidates.length) {
|
|
500
|
+
throw new Error("Unable to derive any fingerprints from the PEM key.");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const md5Fingerprint =
|
|
504
|
+
uniqueCandidates.find((v) => v.includes(":") && v.replace(/:/g, "").length === 32) ||
|
|
505
|
+
null;
|
|
506
|
+
const sha256Fingerprint =
|
|
507
|
+
uniqueCandidates.find((v) => v.startsWith("SHA256:")) || null;
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
pemPath,
|
|
511
|
+
pemFileName: path.basename(pemPath),
|
|
512
|
+
keyType,
|
|
513
|
+
md5Fingerprint,
|
|
514
|
+
sha256Fingerprint,
|
|
515
|
+
candidateValues: uniqueCandidates,
|
|
516
|
+
matchKeys
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function analyzePemKeys(config, warnings) {
|
|
521
|
+
const analyses = [];
|
|
522
|
+
|
|
523
|
+
for (const pemPath of config.pemPaths) {
|
|
524
|
+
try {
|
|
525
|
+
analyses.push(analyzePemKey(pemPath));
|
|
526
|
+
} catch (error) {
|
|
527
|
+
warnings.push(
|
|
528
|
+
`PEM analysis skipped for ${path.basename(pemPath)}: ${error.message || String(error)}`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!analyses.length) {
|
|
534
|
+
throw new Error("No valid PEM keys could be analyzed from the provided paths.");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return analyses;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function normalizeName(value) {
|
|
541
|
+
return String(value || "")
|
|
542
|
+
.toLowerCase()
|
|
543
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function keyMatchResult(instanceKeyName, keypairFingerprints, pemAnalyses, config) {
|
|
547
|
+
if (!instanceKeyName) {
|
|
548
|
+
return { status: "unknown", matchedPemFiles: [], matchedBy: "none" };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const awsFp = keypairFingerprints.get(instanceKeyName);
|
|
552
|
+
if (awsFp) {
|
|
553
|
+
const awsKeys = fingerprintMatchKeys(awsFp);
|
|
554
|
+
const matchedPemFiles = [];
|
|
555
|
+
for (const pemAnalysis of pemAnalyses) {
|
|
556
|
+
for (const key of awsKeys) {
|
|
557
|
+
if (pemAnalysis.matchKeys.has(key)) {
|
|
558
|
+
matchedPemFiles.push(pemAnalysis.pemFileName);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
status: matchedPemFiles.length > 0,
|
|
565
|
+
matchedPemFiles,
|
|
566
|
+
matchedBy: "fingerprint"
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (config.keyName && instanceKeyName === config.keyName) {
|
|
571
|
+
return { status: true, matchedPemFiles: [], matchedBy: "key-name" };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const normalizedInstanceKeyName = normalizeName(instanceKeyName);
|
|
575
|
+
if (normalizedInstanceKeyName) {
|
|
576
|
+
const matchedPemFiles = pemAnalyses
|
|
577
|
+
.filter(
|
|
578
|
+
(pemAnalysis) =>
|
|
579
|
+
normalizedInstanceKeyName ===
|
|
580
|
+
normalizeName(path.parse(pemAnalysis.pemFileName).name)
|
|
581
|
+
)
|
|
582
|
+
.map((pemAnalysis) => pemAnalysis.pemFileName);
|
|
583
|
+
if (matchedPemFiles.length) {
|
|
584
|
+
return { status: true, matchedPemFiles, matchedBy: "pem-name" };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return { status: "unknown", matchedPemFiles: [], matchedBy: "none" };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function parseIniSectionNames(text) {
|
|
592
|
+
const names = new Set();
|
|
593
|
+
if (!text) {
|
|
594
|
+
return names;
|
|
595
|
+
}
|
|
596
|
+
const lines = text.split(/\r?\n/);
|
|
597
|
+
for (const line of lines) {
|
|
598
|
+
const trimmed = line.trim();
|
|
599
|
+
if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const match = /^\[([^\]]+)\]$/.exec(trimmed);
|
|
603
|
+
if (!match) {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
names.add(match[1].trim());
|
|
607
|
+
}
|
|
608
|
+
return names;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function listLocalAwsProfiles() {
|
|
612
|
+
const awsDir = path.join(os.homedir(), ".aws");
|
|
613
|
+
const found = new Set();
|
|
614
|
+
|
|
615
|
+
const credentialsPath = path.join(awsDir, "credentials");
|
|
616
|
+
const configPath = path.join(awsDir, "config");
|
|
617
|
+
|
|
618
|
+
if (fs.existsSync(credentialsPath)) {
|
|
619
|
+
for (const name of parseIniSectionNames(fs.readFileSync(credentialsPath, "utf8"))) {
|
|
620
|
+
if (name) {
|
|
621
|
+
found.add(name);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (fs.existsSync(configPath)) {
|
|
627
|
+
for (const name of parseIniSectionNames(fs.readFileSync(configPath, "utf8"))) {
|
|
628
|
+
if (name === "default") {
|
|
629
|
+
found.add("default");
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (name.startsWith("profile ")) {
|
|
633
|
+
found.add(name.slice("profile ".length).trim());
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return Array.from(found).filter(Boolean).sort();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
let awsModulesCache = null;
|
|
642
|
+
function loadAwsModules() {
|
|
643
|
+
if (awsModulesCache) {
|
|
644
|
+
return awsModulesCache;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const ec2 = require("@aws-sdk/client-ec2");
|
|
648
|
+
const sts = require("@aws-sdk/client-sts");
|
|
649
|
+
const credentialProviders = require("@aws-sdk/credential-providers");
|
|
650
|
+
awsModulesCache = { ec2, sts, credentialProviders };
|
|
651
|
+
return awsModulesCache;
|
|
652
|
+
} catch (error) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
"Missing AWS SDK dependencies. Run: npm install"
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function baseRegion() {
|
|
660
|
+
return (
|
|
661
|
+
envText("AWS_REGION") ||
|
|
662
|
+
envText("AWS_DEFAULT_REGION") ||
|
|
663
|
+
"us-east-1"
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function awsClientConfig(profileName, region) {
|
|
668
|
+
const { credentialProviders } = loadAwsModules();
|
|
669
|
+
const config = { region };
|
|
670
|
+
if (profileName) {
|
|
671
|
+
config.credentials = credentialProviders.fromIni({ profile: profileName });
|
|
672
|
+
}
|
|
673
|
+
return config;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function listRequestedProfiles(config) {
|
|
677
|
+
if (config.profiles && config.profiles.length) {
|
|
678
|
+
return config.profiles.slice();
|
|
679
|
+
}
|
|
680
|
+
const localProfiles = listLocalAwsProfiles();
|
|
681
|
+
return localProfiles.length ? localProfiles : [null];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function discoverRegionsForProfile(profileName, profileLabel, warnings) {
|
|
685
|
+
const { ec2 } = loadAwsModules();
|
|
686
|
+
const client = new ec2.EC2Client(awsClientConfig(profileName, baseRegion()));
|
|
687
|
+
try {
|
|
688
|
+
const response = await client.send(
|
|
689
|
+
new ec2.DescribeRegionsCommand({ AllRegions: false })
|
|
690
|
+
);
|
|
691
|
+
const regions = (response.Regions || [])
|
|
692
|
+
.map((r) => r.RegionName)
|
|
693
|
+
.filter(Boolean);
|
|
694
|
+
if (regions.length) {
|
|
695
|
+
return Array.from(new Set(regions)).sort();
|
|
696
|
+
}
|
|
697
|
+
} catch (error) {
|
|
698
|
+
warnings.push(
|
|
699
|
+
`[${profileLabel}] DescribeRegions failed (${error.name || "Error"}); using fallback region ${baseRegion()}`
|
|
700
|
+
);
|
|
701
|
+
} finally {
|
|
702
|
+
client.destroy();
|
|
703
|
+
}
|
|
704
|
+
return [baseRegion()];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function buildDiscoveryPlan(config, warnings) {
|
|
708
|
+
const { sts } = loadAwsModules();
|
|
709
|
+
const plans = [];
|
|
710
|
+
|
|
711
|
+
for (const profileName of listRequestedProfiles(config)) {
|
|
712
|
+
const profileLabel = profileName || "default";
|
|
713
|
+
const stsClient = new sts.STSClient(awsClientConfig(profileName, baseRegion()));
|
|
714
|
+
try {
|
|
715
|
+
const identity = await stsClient.send(new sts.GetCallerIdentityCommand({}));
|
|
716
|
+
const accountId = identity.Account || "unknown";
|
|
717
|
+
const regions =
|
|
718
|
+
config.regions && config.regions.length
|
|
719
|
+
? config.regions.slice()
|
|
720
|
+
: await discoverRegionsForProfile(profileName, profileLabel, warnings);
|
|
721
|
+
|
|
722
|
+
if (!regions.length) {
|
|
723
|
+
warnings.push(`[${profileLabel}] No regions resolved; profile skipped.`);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
plans.push({
|
|
728
|
+
profileName,
|
|
729
|
+
profileLabel,
|
|
730
|
+
accountId,
|
|
731
|
+
regions
|
|
732
|
+
});
|
|
733
|
+
} catch (error) {
|
|
734
|
+
warnings.push(
|
|
735
|
+
`[${profileLabel}] Credential/IAM validation failed and profile was skipped: ${error.name || "Error"}: ${error.message}`
|
|
736
|
+
);
|
|
737
|
+
} finally {
|
|
738
|
+
stsClient.destroy();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (!plans.length) {
|
|
743
|
+
throw new Error("No usable AWS profiles were found after validation.");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return plans;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function describeKeypairFingerprints(client) {
|
|
750
|
+
const { ec2 } = loadAwsModules();
|
|
751
|
+
const result = new Map();
|
|
752
|
+
let nextToken = undefined;
|
|
753
|
+
|
|
754
|
+
do {
|
|
755
|
+
const resp = await client.send(
|
|
756
|
+
new ec2.DescribeKeyPairsCommand({ NextToken: nextToken })
|
|
757
|
+
);
|
|
758
|
+
for (const item of resp.KeyPairs || []) {
|
|
759
|
+
if (item.KeyName && item.KeyFingerprint) {
|
|
760
|
+
result.set(item.KeyName, item.KeyFingerprint);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
nextToken = resp.NextToken;
|
|
764
|
+
} while (nextToken);
|
|
765
|
+
|
|
766
|
+
return result;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function getTagValue(tags, key) {
|
|
770
|
+
if (!Array.isArray(tags)) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
for (const tag of tags) {
|
|
774
|
+
if (tag && tag.Key === key) {
|
|
775
|
+
return tag.Value || null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function collectEc2Inventory(config, plans, pemAnalyses, warnings) {
|
|
782
|
+
const { ec2 } = loadAwsModules();
|
|
783
|
+
const records = [];
|
|
784
|
+
const stats = { profiles: 0, regions: 0, regionErrors: 0 };
|
|
785
|
+
|
|
786
|
+
for (const plan of plans) {
|
|
787
|
+
stats.profiles += 1;
|
|
788
|
+
|
|
789
|
+
for (const region of plan.regions) {
|
|
790
|
+
stats.regions += 1;
|
|
791
|
+
const client = new ec2.EC2Client(awsClientConfig(plan.profileName, region));
|
|
792
|
+
try {
|
|
793
|
+
const keypairFingerprints = await describeKeypairFingerprints(client);
|
|
794
|
+
let nextToken = undefined;
|
|
795
|
+
|
|
796
|
+
do {
|
|
797
|
+
const resp = await client.send(
|
|
798
|
+
new ec2.DescribeInstancesCommand({ NextToken: nextToken })
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
for (const reservation of resp.Reservations || []) {
|
|
802
|
+
for (const instance of reservation.Instances || []) {
|
|
803
|
+
if (!instance.PublicIpAddress) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const keyName = instance.KeyName || null;
|
|
808
|
+
const matchResult = keyMatchResult(
|
|
809
|
+
keyName,
|
|
810
|
+
keypairFingerprints,
|
|
811
|
+
pemAnalyses,
|
|
812
|
+
config
|
|
813
|
+
);
|
|
814
|
+
records.push({
|
|
815
|
+
profile: plan.profileLabel,
|
|
816
|
+
accountId: plan.accountId,
|
|
817
|
+
region,
|
|
818
|
+
instanceId: instance.InstanceId || null,
|
|
819
|
+
name: getTagValue(instance.Tags, "Name"),
|
|
820
|
+
state: instance.State && instance.State.Name ? instance.State.Name : null,
|
|
821
|
+
publicIp: instance.PublicIpAddress,
|
|
822
|
+
publicDns: instance.PublicDnsName || null,
|
|
823
|
+
keyName,
|
|
824
|
+
keyFingerprintMatch: matchResult.status,
|
|
825
|
+
matchedPemFiles: matchResult.matchedPemFiles,
|
|
826
|
+
matchedBy: matchResult.matchedBy,
|
|
827
|
+
securityGroups: (instance.SecurityGroups || []).map((sg) => ({
|
|
828
|
+
id: sg.GroupId || null,
|
|
829
|
+
name: sg.GroupName || null
|
|
830
|
+
})),
|
|
831
|
+
launchTime: instance.LaunchTime
|
|
832
|
+
? new Date(instance.LaunchTime).toISOString()
|
|
833
|
+
: null,
|
|
834
|
+
sshReachable: null,
|
|
835
|
+
sshTriedUser: null,
|
|
836
|
+
sshTriedPem: null
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
nextToken = resp.NextToken;
|
|
842
|
+
} while (nextToken);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
stats.regionErrors += 1;
|
|
845
|
+
warnings.push(
|
|
846
|
+
`[${plan.profileLabel}/${region}] EC2 collection failed: ${error.name || "Error"}: ${error.message}`
|
|
847
|
+
);
|
|
848
|
+
} finally {
|
|
849
|
+
client.destroy();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return { records, stats };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function trySshOnce(host, username, privateKey, timeoutMs) {
|
|
858
|
+
const { Client } = require("ssh2");
|
|
859
|
+
return await new Promise((resolve, reject) => {
|
|
860
|
+
const conn = new Client();
|
|
861
|
+
let done = false;
|
|
862
|
+
const timer = setTimeout(() => {
|
|
863
|
+
if (done) {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
done = true;
|
|
867
|
+
try {
|
|
868
|
+
conn.end();
|
|
869
|
+
} catch {}
|
|
870
|
+
reject(new Error("SSH timeout"));
|
|
871
|
+
}, timeoutMs);
|
|
872
|
+
|
|
873
|
+
function finish(err, ok) {
|
|
874
|
+
if (done) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
done = true;
|
|
878
|
+
clearTimeout(timer);
|
|
879
|
+
try {
|
|
880
|
+
conn.end();
|
|
881
|
+
} catch {}
|
|
882
|
+
if (err) {
|
|
883
|
+
reject(err);
|
|
884
|
+
} else {
|
|
885
|
+
resolve(ok);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
conn
|
|
890
|
+
.on("ready", () => finish(null, true))
|
|
891
|
+
.on("error", (err) => finish(err, false))
|
|
892
|
+
.on("end", () => {
|
|
893
|
+
if (!done) {
|
|
894
|
+
finish(new Error("SSH connection ended before ready"), false);
|
|
895
|
+
}
|
|
896
|
+
})
|
|
897
|
+
.connect({
|
|
898
|
+
host,
|
|
899
|
+
port: 22,
|
|
900
|
+
username,
|
|
901
|
+
privateKey,
|
|
902
|
+
readyTimeout: timeoutMs
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function sshCheckRecords(config, records, pemAnalyses, warnings) {
|
|
908
|
+
if (!config.sshCheck) {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const privateKeys = [];
|
|
913
|
+
for (const pemAnalysis of pemAnalyses) {
|
|
914
|
+
try {
|
|
915
|
+
privateKeys.push({
|
|
916
|
+
pemPath: pemAnalysis.pemPath,
|
|
917
|
+
pemFileName: pemAnalysis.pemFileName,
|
|
918
|
+
privateKey: fs.readFileSync(pemAnalysis.pemPath)
|
|
919
|
+
});
|
|
920
|
+
} catch (error) {
|
|
921
|
+
warnings.push(
|
|
922
|
+
`SSH key load failed for ${pemAnalysis.pemFileName}: ${error.message || String(error)}`
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (!privateKeys.length) {
|
|
928
|
+
throw new Error("No PEM keys could be loaded for SSH checks.");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
for (const record of records) {
|
|
932
|
+
const host = record.publicIp;
|
|
933
|
+
if (!host) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let reachable = false;
|
|
938
|
+
let triedUser = null;
|
|
939
|
+
let triedPem = null;
|
|
940
|
+
let stopForHost = false;
|
|
941
|
+
|
|
942
|
+
for (const username of config.sshUsernames) {
|
|
943
|
+
for (const key of privateKeys) {
|
|
944
|
+
triedUser = username;
|
|
945
|
+
triedPem = key.pemFileName;
|
|
946
|
+
try {
|
|
947
|
+
await trySshOnce(host, username, key.privateKey, config.sshTimeoutMs);
|
|
948
|
+
reachable = true;
|
|
949
|
+
break;
|
|
950
|
+
} catch (error) {
|
|
951
|
+
const msg = error && error.message ? error.message : String(error);
|
|
952
|
+
const lower = msg.toLowerCase();
|
|
953
|
+
if (
|
|
954
|
+
lower.includes("all configured authentication methods failed") ||
|
|
955
|
+
lower.includes("authentication")
|
|
956
|
+
) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
warnings.push(
|
|
960
|
+
`[SSH ${record.instanceId || "unknown"} ${host} ${key.pemFileName}] ${msg}`
|
|
961
|
+
);
|
|
962
|
+
stopForHost = true;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (reachable || stopForHost) {
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
record.sshReachable = reachable;
|
|
972
|
+
record.sshTriedUser = triedUser;
|
|
973
|
+
record.sshTriedPem = triedPem;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function aggregateResults(config, records) {
|
|
978
|
+
let output = records.slice();
|
|
979
|
+
if (config.matchedOnly) {
|
|
980
|
+
output = output.filter((r) => r.keyFingerprintMatch === true);
|
|
981
|
+
}
|
|
982
|
+
output.sort((a, b) =>
|
|
983
|
+
[
|
|
984
|
+
a.profile || "",
|
|
985
|
+
a.accountId || "",
|
|
986
|
+
a.region || "",
|
|
987
|
+
a.instanceId || ""
|
|
988
|
+
]
|
|
989
|
+
.join("|")
|
|
990
|
+
.localeCompare(
|
|
991
|
+
[b.profile || "", b.accountId || "", b.region || "", b.instanceId || ""].join("|")
|
|
992
|
+
)
|
|
993
|
+
);
|
|
994
|
+
return output;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function csvEscape(value) {
|
|
998
|
+
if (value == null) {
|
|
999
|
+
return "";
|
|
1000
|
+
}
|
|
1001
|
+
const text = String(value);
|
|
1002
|
+
if (/[",\r\n]/.test(text)) {
|
|
1003
|
+
return `"${text.replace(/"/g, "\"\"")}"`;
|
|
1004
|
+
}
|
|
1005
|
+
return text;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function toCsvRow(record) {
|
|
1009
|
+
return {
|
|
1010
|
+
profile: record.profile,
|
|
1011
|
+
accountId: record.accountId,
|
|
1012
|
+
region: record.region,
|
|
1013
|
+
instanceId: record.instanceId,
|
|
1014
|
+
name: record.name,
|
|
1015
|
+
state: record.state,
|
|
1016
|
+
publicIp: record.publicIp,
|
|
1017
|
+
publicDns: record.publicDns,
|
|
1018
|
+
keyName: record.keyName,
|
|
1019
|
+
keyFingerprintMatch: record.keyFingerprintMatch,
|
|
1020
|
+
matchedPemFiles: Array.isArray(record.matchedPemFiles)
|
|
1021
|
+
? record.matchedPemFiles.join(";")
|
|
1022
|
+
: "",
|
|
1023
|
+
matchedBy: record.matchedBy,
|
|
1024
|
+
securityGroups: (record.securityGroups || [])
|
|
1025
|
+
.map((sg) => `${sg.id || ""}:${sg.name || ""}`.replace(/:$/, ""))
|
|
1026
|
+
.join(";"),
|
|
1027
|
+
launchTime: record.launchTime,
|
|
1028
|
+
sshReachable: record.sshReachable,
|
|
1029
|
+
sshTriedUser: record.sshTriedUser,
|
|
1030
|
+
sshTriedPem: record.sshTriedPem
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function renderOutput(config, outputRecords) {
|
|
1035
|
+
if (config.format === "json") {
|
|
1036
|
+
return JSON.stringify(outputRecords, null, 2);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const fields = [
|
|
1040
|
+
"profile",
|
|
1041
|
+
"accountId",
|
|
1042
|
+
"region",
|
|
1043
|
+
"instanceId",
|
|
1044
|
+
"name",
|
|
1045
|
+
"state",
|
|
1046
|
+
"publicIp",
|
|
1047
|
+
"publicDns",
|
|
1048
|
+
"keyName",
|
|
1049
|
+
"keyFingerprintMatch",
|
|
1050
|
+
"matchedPemFiles",
|
|
1051
|
+
"matchedBy",
|
|
1052
|
+
"securityGroups",
|
|
1053
|
+
"launchTime",
|
|
1054
|
+
"sshReachable",
|
|
1055
|
+
"sshTriedUser",
|
|
1056
|
+
"sshTriedPem"
|
|
1057
|
+
];
|
|
1058
|
+
const lines = [fields.join(",")];
|
|
1059
|
+
for (const record of outputRecords) {
|
|
1060
|
+
const row = toCsvRow(record);
|
|
1061
|
+
lines.push(fields.map((f) => csvEscape(row[f])).join(","));
|
|
1062
|
+
}
|
|
1063
|
+
return lines.join("\n");
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function writeOrPrint(config, content) {
|
|
1067
|
+
if (config.outPath) {
|
|
1068
|
+
fs.writeFileSync(config.outPath, content, "utf8");
|
|
1069
|
+
eprint(`Wrote ${config.format.toUpperCase()} output to ${config.outPath}`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
process.stdout.write(content);
|
|
1073
|
+
if (!content.endsWith("\n")) {
|
|
1074
|
+
process.stdout.write("\n");
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function runWorkflow(config) {
|
|
1079
|
+
const warnings = [];
|
|
1080
|
+
|
|
1081
|
+
progress(config, 1, "orchestrator: parse CLI/env parameters");
|
|
1082
|
+
eprint(
|
|
1083
|
+
`Inputs: profiles=${config.profiles ? config.profiles.join(",") : "auto"}, regions=${config.regions ? config.regions.join(",") : "auto"}, format=${config.format}, ssh_check=${config.sshCheck ? "on" : "off"}`
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
progress(config, 2, "config_validator: validate PEM path(s), dependencies, output path");
|
|
1087
|
+
warnings.push(...validateConfig(config));
|
|
1088
|
+
|
|
1089
|
+
progress(config, 3, "pem_key_analyzer: load PEM key(s) and compute fingerprint candidates");
|
|
1090
|
+
const pemAnalyses = analyzePemKeys(config, warnings);
|
|
1091
|
+
eprint(`PEM analysis: loaded=${pemAnalyses.length}/${config.pemPaths.length} key(s)`);
|
|
1092
|
+
for (const pemAnalysis of pemAnalyses) {
|
|
1093
|
+
eprint(
|
|
1094
|
+
` - ${pemAnalysis.pemFileName}: type=${pemAnalysis.keyType}, md5=${pemAnalysis.md5Fingerprint || "n/a"}, sha256=${pemAnalysis.sha256Fingerprint || "n/a"}`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
progress(config, 4, "aws_discovery_planner: validate credentials and resolve profile/region plan");
|
|
1099
|
+
const plans = await buildDiscoveryPlan(config, warnings);
|
|
1100
|
+
eprint(
|
|
1101
|
+
"Discovery plan: " +
|
|
1102
|
+
plans.map((p) => `${p.profileLabel}(${p.regions.length} regions)`).join(", ")
|
|
1103
|
+
);
|
|
1104
|
+
|
|
1105
|
+
progress(config, 5, "ec2_inventory_collector: collect EC2 public IP inventory and key pairs");
|
|
1106
|
+
const { records, stats } = await collectEc2Inventory(
|
|
1107
|
+
config,
|
|
1108
|
+
plans,
|
|
1109
|
+
pemAnalyses,
|
|
1110
|
+
warnings
|
|
1111
|
+
);
|
|
1112
|
+
eprint(`Collected public-IP instances: ${records.length}`);
|
|
1113
|
+
|
|
1114
|
+
progress(config, 6, "ssh_connectivity_checker: optional SSH checks");
|
|
1115
|
+
await sshCheckRecords(config, records, pemAnalyses, warnings);
|
|
1116
|
+
|
|
1117
|
+
progress(config, 7, "result_aggregator: merge inventory and SSH results");
|
|
1118
|
+
const outputRecords = aggregateResults(config, records);
|
|
1119
|
+
|
|
1120
|
+
progress(config, 8, "cli_output_formatter: render JSON/CSV");
|
|
1121
|
+
const rendered = renderOutput(config, outputRecords);
|
|
1122
|
+
writeOrPrint(config, rendered);
|
|
1123
|
+
|
|
1124
|
+
progress(config, 9, "END: summarize execution");
|
|
1125
|
+
const matched = outputRecords.filter((r) => r.keyFingerprintMatch === true).length;
|
|
1126
|
+
const sshChecked = outputRecords.filter((r) => r.sshReachable !== null).length;
|
|
1127
|
+
const sshReachable = outputRecords.filter((r) => r.sshReachable === true).length;
|
|
1128
|
+
eprint(
|
|
1129
|
+
`Summary: pem_keys=${pemAnalyses.length}, profiles=${stats.profiles}, regions_scanned=${stats.regions}, region_errors=${stats.regionErrors}, output_records=${outputRecords.length}, matched=${matched}, ssh_checked=${sshChecked}, ssh_reachable=${sshReachable}, warnings=${warnings.length}`
|
|
1130
|
+
);
|
|
1131
|
+
for (const warning of warnings) {
|
|
1132
|
+
eprint(`WARNING: ${warning}`);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return stats.regionErrors > 0 || warnings.length > 0 ? 2 : 0;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function main() {
|
|
1139
|
+
try {
|
|
1140
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
1141
|
+
if (parsed.help) {
|
|
1142
|
+
process.stdout.write(usageText());
|
|
1143
|
+
process.exitCode = 0;
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const exitCode = await runWorkflow(parsed);
|
|
1147
|
+
process.exitCode = exitCode;
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
if (error && error.message) {
|
|
1150
|
+
eprint(`ERROR: ${error.message}`);
|
|
1151
|
+
} else {
|
|
1152
|
+
eprint(`ERROR: ${String(error)}`);
|
|
1153
|
+
}
|
|
1154
|
+
process.exitCode = 1;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (require.main === module) {
|
|
1159
|
+
void main();
|
|
1160
|
+
}
|