microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
9575cd22b63530036de8f7fd84b82d6ca8cd8a1f

Branches

Tags

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

Clone

HTTPS

Download ZIP

ts/tools/scripts/getKeys.mjs

471lines · 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 p = [];
95 for (const secret of secretList) {
96 if (secret.attributes.enabled) {
97 const secretName = secret.id.split("/").pop();
98 p.push(
99 (async () => {
100 const response = await keyVaultClient.readSecret(
101 vaultName,
102 secretName,
103 );
104 return [secretName, response.value];
105 })(),
106 );
107 }
108 }
109
110 return Promise.all(p);
111}
112
113async function execAsync(command, options) {
114 return new Promise((res, rej) => {
115 child_process.exec(command, options, (err, stdout, stderr) => {
116 if (err) {
117 rej(err);
118 return;
119 }
120 if (stderr) {
121 console.log(stderr + stdout);
122 }
123 res(stdout);
124 });
125 });
126}
127
128class AzCliKeyVaultClient {
129 static async get() {
130 // We use this to validate that the user is logged in (already ran `az login`).
131 try {
132 const account = JSON.parse(await execAsync("az account show"));
133 console.log(`Logged in as ${chalk.cyanBright(account.user.name)}`);
134 } catch (e) {
135 console.error(
136 "ERROR: User not logged in to Azure CLI. Run 'az login'.",
137 );
138 process.exit(1);
139 }
140 // Note: 'az keyvault' commands work regardless of which subscription is currently "in context",
141 // as long as the user is listed in the vault's access policy, so we don't need to do 'az account set'.
142 return new AzCliKeyVaultClient();
143 }
144
145 async getSecrets(vaultName) {
146 return JSON.parse(
147 await execAsync(
148 `az keyvault secret list --vault-name ${vaultName}`,
149 ),
150 );
151 }
152
153 async readSecret(vaultName, secretName) {
154 return JSON.parse(
155 await execAsync(
156 `az keyvault secret show --vault-name ${vaultName} --name ${secretName}`,
157 ),
158 );
159 }
160
161 async writeSecret(vaultName, secretName, secretValue) {
162 return JSON.parse(
163 await execAsync(
164 `az keyvault secret set --vault-name ${vaultName} --name ${secretName} --value '${secretValue}'`,
165 ),
166 );
167 }
168}
169
170async function getKeyVaultClient() {
171 return AzCliKeyVaultClient.get();
172}
173
174async function readDotenv() {
175 if (!fs.existsSync(dotenvPath)) {
176 return [];
177 }
178 const dotenvFile = await fs.promises.readFile(dotenvPath, "utf8");
179 const dotEnv = dotenvFile
180 .split(/\r?\n/)
181 .filter((line) => {
182 const trimmed = line.trim();
183 return trimmed !== "" && !trimmed.startsWith("#");
184 })
185 .map((line) => {
186 const [key, ...value] = line.split("=");
187 const trimmedKey = key.trim();
188 if (trimmedKey.includes("-")) {
189 throw new Error(
190 `Invalid dotenv key '${trimmedKey}' for key vault. Keys cannot contain dashes.`,
191 );
192 }
193 return [trimmedKey, value.join("=").trimEnd()];
194 });
195 return dotEnv;
196}
197
198function toSecretKey(envKey) {
199 return envKey.split("_").join("-");
200}
201
202function toEnvKey(secretKey) {
203 return secretKey.split("-").join("_");
204}
205
206// Return 0 if the value is the same. -1 if the user skipped. 1 if the value was updated.
207async function pushSecret(
208 stdio,
209 keyVaultClient,
210 vault,
211 secrets,
212 secretKey,
213 value,
214 shared = true,
215) {
216 const suffix = shared ? "" : " (private)";
217 const secretValue = secrets.get(secretKey);
218 if (secretValue === value) {
219 return 0;
220 }
221 if (secrets.has(secretKey)) {
222 const answer = await stdio.question(
223 ` ${secretKey} changed.\n Current value: ${secretValue}\n New value: ${value}\n Are you sure you want to overwrite the value of ${secretKey}? (y/n)`,
224 );
225 if (answer.toLowerCase() !== "y") {
226 console.log("Skipping...");
227 return -1;
228 }
229 console.log(` Overwriting ${secretKey}${suffix}`);
230 } else {
231 console.log(` Creating ${secretKey}${suffix}`);
232 }
233 await keyVaultClient.writeSecret(vault, secretKey, value);
234 return 1;
235}
236
237function getVaultNames(dotEnv) {
238 return {
239 shared:
240 paramSharedVault ??
241 dotEnv.get("TYPEAGENT_SHAREDVAULT") ??
242 config.vault.shared,
243 private:
244 paramPrivateVault ??
245 dotEnv.get("TYPEAGENT_PRIVATEVAULT") ??
246 undefined,
247 };
248}
249
250async function pushSecrets() {
251 const dotEnv = await readDotenv();
252 const keyVaultClient = await getKeyVaultClient();
253 const vaultNames = getVaultNames(dotEnv);
254 const sharedSecrets = new Map(
255 await getSecrets(keyVaultClient, vaultNames.shared, true),
256 );
257 const privateSecrets = new Map(
258 vaultNames.private
259 ? await getSecrets(keyVaultClient, vaultNames.private, false)
260 : undefined,
261 );
262
263 console.log(`Pushing secrets from ${dotenvPath} to key vault.`);
264 let updated = 0;
265 let skipped = 0;
266 const stdio = readline.createInterface(process.stdin, process.stdout);
267 try {
268 for (const [envKey, value] of dotEnv) {
269 const secretKey = toSecretKey(envKey);
270 if (sharedKeys.includes(envKey)) {
271 const result = await pushSecret(
272 stdio,
273 keyVaultClient,
274 vaultNames.shared,
275 sharedSecrets,
276 secretKey,
277 value,
278 );
279 if (result === 1) {
280 updated++;
281 }
282 if (result === -1) {
283 skipped++;
284 }
285 } else if (privateKeys.includes(envKey)) {
286 if (privateVault === undefined) {
287 console.log(` Skipping private key ${envKey}.`);
288 continue;
289 }
290 const result = await pushSecret(
291 stdio,
292 keyVaultClient,
293 vaultNames.private,
294 privateSecrets,
295 secretKey,
296 value,
297 false,
298 );
299 if (result === 1) {
300 updated++;
301 }
302 if (result === -1) {
303 skipped++;
304 }
305 } else {
306 console.log(` Skipping unknown key ${envKey}.`);
307 }
308 }
309 } finally {
310 stdio.close();
311 }
312 if (skipped === 0 && updated === 0) {
313 console.log("All values up to date in key vault.");
314 return;
315 }
316 if (skipped !== 0) {
317 console.log(`${skipped} secrets skipped.`);
318 }
319 if (updated !== 0) {
320 console.log(`${updated} secrets updated.`);
321 }
322}
323
324async function pullSecretsFromVault(keyVaultClient, vaultName, shared, dotEnv) {
325 const keys = shared ? sharedKeys : privateKeys;
326 const secrets = await getSecrets(keyVaultClient, vaultName, shared);
327 if (secrets.length === 0) {
328 console.log(
329 chalk.yellow(
330 `WARNING: No secrets found in key vault ${chalk.cyanBright(vaultName)}.`,
331 ),
332 );
333 return undefined;
334 }
335
336 let updated = 0;
337 for (const [secretKey, value] of secrets) {
338 const envKey = toEnvKey(secretKey);
339 if (keys.includes(envKey) && dotEnv.get(envKey) !== value) {
340 console.log(` Updating ${envKey}`);
341 dotEnv.set(envKey, value);
342 updated++;
343 }
344 }
345 return updated;
346}
347
348async function pullSecrets() {
349 const dotEnv = new Map(await readDotenv());
350 const keyVaultClient = await getKeyVaultClient();
351 const vaultNames = getVaultNames(dotEnv);
352 console.log(`Pulling secrets to ${chalk.cyanBright(dotenvPath)}`);
353 const sharedUpdated = await pullSecretsFromVault(
354 keyVaultClient,
355 vaultNames.shared,
356 true,
357 dotEnv,
358 );
359 const privateUpdated = vaultNames.private
360 ? await pullSecretsFromVault(
361 keyVaultClient,
362 vaultNames.private,
363 false,
364 dotEnv,
365 )
366 : undefined;
367
368 if (sharedUpdated === undefined && privateUpdated === undefined) {
369 throw new Error("No secrets found in key vaults.");
370 }
371
372 let updated = (sharedUpdated ?? 0) + (privateUpdated ?? 0);
373 for (const key of deleteKeys) {
374 if (dotEnv.has(key)) {
375 console.log(` Deleting ${key}`);
376 dotEnv.delete(key);
377 updated++;
378 }
379 }
380 if (dotEnv.get("TYPEAGENT_SHAREDVAULT") !== vaultNames.shared) {
381 console.log(` Updating TYPEAGENT_SHAREDVAULT`);
382 dotEnv.set("TYPEAGENT_SHAREDVAULT", vaultNames.shared);
383 updated++;
384 }
385 if (
386 vaultNames.private &&
387 dotEnv.get("TYPEAGENT_PRIVATEVAULT") !== vaultNames.private
388 ) {
389 console.log(` Updating TYPEAGENT_PRIVATEVAULT`);
390 dotEnv.set("TYPEAGENT_PRIVATEVAULT", vaultNames.private);
391 updated++;
392 }
393
394 if (updated === 0) {
395 console.log(
396 `\nAll values up to date in ${chalk.cyanBright(dotenvPath)}`,
397 );
398 return;
399 }
400 console.log(
401 `\n${updated} values updated.\nWriting '${chalk.cyanBright(dotenvPath)}'.`,
402 );
403
404 await fs.promises.writeFile(
405 dotenvPath,
406 [...dotEnv.entries()]
407 .map(([key, value]) => (key ? `${key}=${value}` : ""))
408 .join("\n"),
409 );
410}
411
412const commands = ["push", "pull", "help"];
413(async () => {
414 const command = commands.includes(process.argv[2])
415 ? process.argv[2]
416 : undefined;
417 const start = command !== undefined ? 3 : 2;
418 for (let i = start; i < process.argv.length; i++) {
419 const arg = process.argv[i];
420 if (arg === "--vault") {
421 paramSharedVault = process.argv[i + 1];
422 if (paramSharedVault === undefined) {
423 throw new Error("Missing value for --vault");
424 }
425 i++;
426 continue;
427 }
428
429 if (arg === "--private") {
430 paramPrivateVault = process.argv[i + 1];
431 if (paramPrivateVault === undefined) {
432 throw new Error("Missing value for --private");
433 }
434 i++;
435 continue;
436 }
437
438 throw new Error(`Unknown argument: ${arg}`);
439 }
440 switch (command) {
441 case "push":
442 await pushSecrets();
443 break;
444 case "pull":
445 case undefined:
446 await pullSecrets();
447 break;
448 case "help":
449 printHelp();
450 return;
451 default:
452 throw new Error(`Unknown argument '${process.argv[2]}'`);
453 }
454})().catch((e) => {
455 if (
456 e.message.includes(
457 "'az' is not recognized as an internal or external command",
458 )
459 ) {
460 console.error(
461 chalk.red(
462 `ERROR: Azure CLI is not installed. Install it and run 'az login' before running this tool.`,
463 ),
464 );
465 // eslint-disable-next-line no-undef
466 exit(0);
467 }
468
469 console.error(chalk.red(`FATAL ERROR: ${e.stack}`));
470 process.exit(-1);
471});
472