microsoft/TypeAgent

Public

mirrored from https://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
61fc5ba0d4792bc9cf0f4696a3ab1fcbc9e9b490

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

ts/tools/scripts/getKeys.mjs

526lines · modecode

1#!/usr/bin/env node
2// Copyright (c) Microsoft Corporation.
3// Licensed under the MIT License.
4
5import fs from "node:fs";
6import path from "node:path";
7import child_process from "node:child_process";
8import readline from "node:readline/promises";
9import { getClient as getPIMClient } from "./lib/pimClient.mjs";
10import { fileURLToPath } from "node:url";
11import { createRequire } from "node:module";
12import chalk from "chalk";
13import { exit } from "node:process";
14
15const require = createRequire(import.meta.url);
16const config = require("./getKeys.config.json");
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const dotenvPath = path.resolve(__dirname, config.defaultDotEnvPath);
20const sharedKeys = config.env.shared;
21const privateKeys = config.env.private;
22const deleteKeys = config.env.delete;
23let paramSharedVault = undefined;
24let paramPrivateVault = undefined;
25
26async function getSecretListWithElevation(keyVaultClient, vaultName) {
27 try {
28 return await keyVaultClient.getSecrets(vaultName);
29 } catch (e) {
30 if (!e.message.includes("ForbiddenByRbac")) {
31 throw e;
32 }
33
34 try {
35 console.warn(chalk.yellowBright("Elevating to get secrets..."));
36 const pimClient = await getPIMClient();
37 await pimClient.elevate({
38 requestType: "SelfActivate",
39 roleName: "Key Vault Administrator",
40 expirationType: "AfterDuration",
41 expirationDuration: "PT5M", // activate for 5 minutes
42 continueOnFailure: true,
43 });
44
45 // Wait for the role to be activated
46 console.log(chalk.green("Elevation successful."));
47 console.warn(chalk.yellowBright("Waiting 5 seconds..."));
48 await new Promise((res) => setTimeout(res, 5000));
49
50 return await keyVaultClient.getSecrets(vaultName);
51 } catch (e) {
52 console.warn(
53 chalk.yellow(
54 "Elevation to key vault admin failed...attempting to get secrets as key vault reader.",
55 ),
56 );
57 }
58
59 try {
60 console.warn(chalk.yellowBright("Elevating to get secrets..."));
61 const pimClient = await getPIMClient();
62 await pimClient.elevate({
63 requestType: "SelfActivate",
64 roleName: "Key Vault Secrets User",
65 expirationType: "AfterDuration",
66 expirationDuration: "PT5M", // activate for 5 minutes
67 continueOnFailure: true,
68 });
69
70 // Wait for the role to be activated
71 console.log(chalk.green("Elevation successful."));
72 console.warn(chalk.yellowBright("Waiting 5 seconds..."));
73 await new Promise((res) => setTimeout(res, 5000));
74 } catch (e) {
75 console.warn(
76 chalk.yellow(
77 "Elevation failed...attempting to get secrets without elevation.",
78 ),
79 );
80 }
81
82 return await keyVaultClient.getSecrets(vaultName);
83 }
84}
85
86async function getSecrets(keyVaultClient, vaultName, shared) {
87 console.log(
88 `Getting existing ${shared ? "shared" : "private"} secrets from ${chalk.cyanBright(vaultName)} key vault.`,
89 );
90 const secretList = await getSecretListWithElevation(
91 keyVaultClient,
92 vaultName,
93 );
94 const enabled = secretList
95 .filter((s) => s.attributes.enabled)
96 .map((s) => s.id.split("/").pop());
97
98 const results = [];
99 const failures = [];
100 const concurrency = 5;
101 for (let i = 0; i < enabled.length; i += concurrency) {
102 const batch = enabled.slice(i, i + concurrency);
103 const batchResults = await Promise.all(
104 batch.map(async (secretName) => {
105 try {
106 const response = await keyVaultClient.readSecret(
107 vaultName,
108 secretName,
109 );
110 return [secretName, response.value];
111 } catch (e) {
112 failures.push({ name: secretName, error: e.message });
113 return null;
114 }
115 }),
116 );
117 results.push(...batchResults.filter((r) => r !== null));
118 }
119
120 return { results, failures };
121}
122
123async function execAsync(command, options) {
124 return new Promise((res, rej) => {
125 child_process.exec(command, options, (err, stdout, stderr) => {
126 if (err) {
127 rej(err);
128 return;
129 }
130 if (stderr) {
131 console.log(stderr + stdout);
132 }
133 res(stdout);
134 });
135 });
136}
137
138async function execWithRetry(command, options, maxRetries = 3) {
139 const SSL_ERROR = "SSL: UNEXPECTED_EOF_WHILE_READING";
140 for (let attempt = 1; attempt <= maxRetries; attempt++) {
141 try {
142 return await execAsync(command, options);
143 } catch (e) {
144 if (attempt < maxRetries && e.message.includes(SSL_ERROR)) {
145 const delay = attempt * 1000;
146 console.warn(
147 chalk.yellow(
148 `SSL error on attempt ${attempt}/${maxRetries}, retrying in ${delay}ms...`,
149 ),
150 );
151 await new Promise((res) => setTimeout(res, delay));
152 } else {
153 throw e;
154 }
155 }
156 }
157}
158
159class AzCliKeyVaultClient {
160 static async get() {
161 // We use this to validate that the user is logged in (already ran `az login`).
162 try {
163 const account = JSON.parse(await execAsync("az account show"));
164 console.log(`Logged in as ${chalk.cyanBright(account.user.name)}`);
165 } catch (e) {
166 console.error(
167 "ERROR: User not logged in to Azure CLI. Run 'az login'.",
168 );
169 process.exit(1);
170 }
171 // Note: 'az keyvault' commands work regardless of which subscription is currently "in context",
172 // as long as the user is listed in the vault's access policy, so we don't need to do 'az account set'.
173 return new AzCliKeyVaultClient();
174 }
175
176 async getSecrets(vaultName) {
177 return JSON.parse(
178 await execWithRetry(
179 `az keyvault secret list --vault-name ${vaultName}`,
180 ),
181 );
182 }
183
184 async readSecret(vaultName, secretName) {
185 return JSON.parse(
186 await execWithRetry(
187 `az keyvault secret show --vault-name ${vaultName} --name ${secretName}`,
188 ),
189 );
190 }
191
192 async writeSecret(vaultName, secretName, secretValue) {
193 return JSON.parse(
194 await execAsync(
195 `az keyvault secret set --vault-name ${vaultName} --name ${secretName} --value '${secretValue}'`,
196 ),
197 );
198 }
199}
200
201async function getKeyVaultClient() {
202 return AzCliKeyVaultClient.get();
203}
204
205async function readDotenv() {
206 if (!fs.existsSync(dotenvPath)) {
207 return [];
208 }
209 const dotenvFile = await fs.promises.readFile(dotenvPath, "utf8");
210 const dotEnv = dotenvFile
211 .split(/\r?\n/)
212 .filter((line) => {
213 const trimmed = line.trim();
214 return trimmed !== "" && !trimmed.startsWith("#");
215 })
216 .map((line) => {
217 const [key, ...value] = line.split("=");
218 const trimmedKey = key.trim();
219 if (trimmedKey.includes("-")) {
220 throw new Error(
221 `Invalid dotenv key '${trimmedKey}' for key vault. Keys cannot contain dashes.`,
222 );
223 }
224 return [trimmedKey, value.join("=").trimEnd()];
225 });
226 return dotEnv;
227}
228
229function toSecretKey(envKey) {
230 return envKey.split("_").join("-");
231}
232
233function toEnvKey(secretKey) {
234 return secretKey.split("-").join("_");
235}
236
237// Return 0 if the value is the same. -1 if the user skipped. 1 if the value was updated.
238async function pushSecret(
239 stdio,
240 keyVaultClient,
241 vault,
242 secrets,
243 secretKey,
244 value,
245 shared = true,
246) {
247 const suffix = shared ? "" : " (private)";
248 const secretValue = secrets.get(secretKey);
249 if (secretValue === value) {
250 return 0;
251 }
252 if (secrets.has(secretKey)) {
253 const answer = await stdio.question(
254 ` ${secretKey} changed.\n Current value: ${secretValue}\n New value: ${value}\n Are you sure you want to overwrite the value of ${secretKey}? (y/n)`,
255 );
256 if (answer.toLowerCase() !== "y") {
257 console.log("Skipping...");
258 return -1;
259 }
260 console.log(` Overwriting ${secretKey}${suffix}`);
261 } else {
262 console.log(` Creating ${secretKey}${suffix}`);
263 }
264 await keyVaultClient.writeSecret(vault, secretKey, value);
265 return 1;
266}
267
268function getVaultNames(dotEnv) {
269 return {
270 shared:
271 paramSharedVault ??
272 dotEnv.get("TYPEAGENT_SHAREDVAULT") ??
273 config.vault.shared,
274 private:
275 paramPrivateVault ??
276 dotEnv.get("TYPEAGENT_PRIVATEVAULT") ??
277 undefined,
278 };
279}
280
281async function pushSecrets() {
282 const dotEnv = await readDotenv();
283 const keyVaultClient = await getKeyVaultClient();
284 const vaultNames = getVaultNames(dotEnv);
285 const sharedSecrets = new Map(
286 await getSecrets(keyVaultClient, vaultNames.shared, true),
287 );
288 const privateSecrets = new Map(
289 vaultNames.private
290 ? await getSecrets(keyVaultClient, vaultNames.private, false)
291 : undefined,
292 );
293
294 console.log(`Pushing secrets from ${dotenvPath} to key vault.`);
295 let updated = 0;
296 let skipped = 0;
297 const stdio = readline.createInterface(process.stdin, process.stdout);
298 try {
299 for (const [envKey, value] of dotEnv) {
300 const secretKey = toSecretKey(envKey);
301 if (sharedKeys.includes(envKey)) {
302 const result = await pushSecret(
303 stdio,
304 keyVaultClient,
305 vaultNames.shared,
306 sharedSecrets,
307 secretKey,
308 value,
309 );
310 if (result === 1) {
311 updated++;
312 }
313 if (result === -1) {
314 skipped++;
315 }
316 } else if (privateKeys.includes(envKey)) {
317 if (privateVault === undefined) {
318 console.log(` Skipping private key ${envKey}.`);
319 continue;
320 }
321 const result = await pushSecret(
322 stdio,
323 keyVaultClient,
324 vaultNames.private,
325 privateSecrets,
326 secretKey,
327 value,
328 false,
329 );
330 if (result === 1) {
331 updated++;
332 }
333 if (result === -1) {
334 skipped++;
335 }
336 } else {
337 console.log(` Skipping unknown key ${envKey}.`);
338 }
339 }
340 } finally {
341 stdio.close();
342 }
343 if (skipped === 0 && updated === 0) {
344 console.log("All values up to date in key vault.");
345 return;
346 }
347 if (skipped !== 0) {
348 console.log(`${skipped} secrets skipped.`);
349 }
350 if (updated !== 0) {
351 console.log(`${updated} secrets updated.`);
352 }
353}
354
355async function pullSecretsFromVault(keyVaultClient, vaultName, shared, dotEnv) {
356 const keys = shared ? sharedKeys : privateKeys;
357 const { results: secrets, failures } = await getSecrets(
358 keyVaultClient,
359 vaultName,
360 shared,
361 );
362 if (secrets.length === 0) {
363 console.log(
364 chalk.yellow(
365 `WARNING: No secrets found in key vault ${chalk.cyanBright(vaultName)}.`,
366 ),
367 );
368 return { updated: undefined, failures };
369 }
370
371 let updated = 0;
372 for (const [secretKey, value] of secrets) {
373 const envKey = toEnvKey(secretKey);
374 if (keys.includes(envKey) && dotEnv.get(envKey) !== value) {
375 console.log(` Updating ${envKey}`);
376 dotEnv.set(envKey, value);
377 updated++;
378 }
379 }
380 return { updated, failures };
381}
382
383async function pullSecrets() {
384 const dotEnv = new Map(await readDotenv());
385 const keyVaultClient = await getKeyVaultClient();
386 const vaultNames = getVaultNames(dotEnv);
387 console.log(`Pulling secrets to ${chalk.cyanBright(dotenvPath)}`);
388 const sharedResult = await pullSecretsFromVault(
389 keyVaultClient,
390 vaultNames.shared,
391 true,
392 dotEnv,
393 );
394 const privateResult = vaultNames.private
395 ? await pullSecretsFromVault(
396 keyVaultClient,
397 vaultNames.private,
398 false,
399 dotEnv,
400 )
401 : undefined;
402
403 if (
404 sharedResult.updated === undefined &&
405 privateResult?.updated === undefined
406 ) {
407 throw new Error("No secrets found in key vaults.");
408 }
409
410 const allFailures = [
411 ...(sharedResult.failures ?? []),
412 ...(privateResult?.failures ?? []),
413 ];
414
415 let updated = (sharedResult.updated ?? 0) + (privateResult?.updated ?? 0);
416 for (const key of deleteKeys) {
417 if (dotEnv.has(key)) {
418 console.log(` Deleting ${key}`);
419 dotEnv.delete(key);
420 updated++;
421 }
422 }
423 if (dotEnv.get("TYPEAGENT_SHAREDVAULT") !== vaultNames.shared) {
424 console.log(` Updating TYPEAGENT_SHAREDVAULT`);
425 dotEnv.set("TYPEAGENT_SHAREDVAULT", vaultNames.shared);
426 updated++;
427 }
428 if (
429 vaultNames.private &&
430 dotEnv.get("TYPEAGENT_PRIVATEVAULT") !== vaultNames.private
431 ) {
432 console.log(` Updating TYPEAGENT_PRIVATEVAULT`);
433 dotEnv.set("TYPEAGENT_PRIVATEVAULT", vaultNames.private);
434 updated++;
435 }
436
437 if (allFailures.length > 0) {
438 console.warn(
439 chalk.yellow(
440 `\nWARNING: Failed to fetch ${allFailures.length} secret(s) — these values were not updated in .env:`,
441 ),
442 );
443 for (const { name, error } of allFailures) {
444 console.warn(chalk.yellow(` - ${name}: ${error}`));
445 }
446 process.exitCode = 1;
447 }
448
449 if (updated === 0) {
450 console.log(
451 `\nAll values up to date in ${chalk.cyanBright(dotenvPath)}`,
452 );
453 return;
454 }
455 console.log(
456 `\n${updated} values updated.\nWriting '${chalk.cyanBright(dotenvPath)}'.`,
457 );
458
459 await fs.promises.writeFile(
460 dotenvPath,
461 [...dotEnv.entries()]
462 .map(([key, value]) => (key ? `${key}=${value}` : ""))
463 .join("\n"),
464 );
465}
466
467const commands = ["push", "pull", "help"];
468(async () => {
469 const command = commands.includes(process.argv[2])
470 ? process.argv[2]
471 : undefined;
472 const start = command !== undefined ? 3 : 2;
473 for (let i = start; i < process.argv.length; i++) {
474 const arg = process.argv[i];
475 if (arg === "--vault") {
476 paramSharedVault = process.argv[i + 1];
477 if (paramSharedVault === undefined) {
478 throw new Error("Missing value for --vault");
479 }
480 i++;
481 continue;
482 }
483
484 if (arg === "--private") {
485 paramPrivateVault = process.argv[i + 1];
486 if (paramPrivateVault === undefined) {
487 throw new Error("Missing value for --private");
488 }
489 i++;
490 continue;
491 }
492
493 throw new Error(`Unknown argument: ${arg}`);
494 }
495 switch (command) {
496 case "push":
497 await pushSecrets();
498 break;
499 case "pull":
500 case undefined:
501 await pullSecrets();
502 break;
503 case "help":
504 printHelp();
505 return;
506 default:
507 throw new Error(`Unknown argument '${process.argv[2]}'`);
508 }
509})().catch((e) => {
510 if (
511 e.message.includes(
512 "'az' is not recognized as an internal or external command",
513 )
514 ) {
515 console.error(
516 chalk.red(
517 `ERROR: Azure CLI is not installed. Install it and run 'az login' before running this tool.`,
518 ),
519 );
520 // eslint-disable-next-line no-undef
521 exit(0);
522 }
523
524 console.error(chalk.red(`FATAL ERROR: ${e.stack}`));
525 process.exit(-1);
526});