akv-manager 0.0.1
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/README.md +83 -0
- package/package.json +39 -0
- package/public/index.html +1001 -0
- package/server.js +552 -0
package/server.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* akv-manager — a small local Express server that uses your Azure CLI login
|
|
6
|
+
* to manage accounts, subscriptions and Key Vaults.
|
|
7
|
+
*
|
|
8
|
+
* It runs every command as the currently logged-in `az` user, so it has exactly
|
|
9
|
+
* the permissions that `az login` granted — nothing more. Bound to 127.0.0.1 by
|
|
10
|
+
* default so it is not reachable from the network.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const { execFile } = require('child_process');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const AZ = process.env.AZ_PATH || 'az';
|
|
18
|
+
const PORT = Number(process.env.PORT || 4173);
|
|
19
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
20
|
+
// az can be slow on the first call of a session (token refresh, extension load).
|
|
21
|
+
const AZ_TIMEOUT = Number(process.env.AZ_TIMEOUT || 120000);
|
|
22
|
+
|
|
23
|
+
const app = express();
|
|
24
|
+
app.use(express.json());
|
|
25
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
26
|
+
|
|
27
|
+
// ---- helpers ---------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const NAME_RE = /^[A-Za-z0-9-]{1,127}$/; // vault / secret / key names
|
|
30
|
+
const SUB_RE = /^[0-9a-fA-F-]{1,40}$|^[\w .()-]{1,80}$/; // sub id or display name
|
|
31
|
+
|
|
32
|
+
function isValidName(v) { return typeof v === 'string' && NAME_RE.test(v); }
|
|
33
|
+
|
|
34
|
+
/** Run az with an args array (no shell => no injection). Returns parsed JSON. */
|
|
35
|
+
function az(args, { json = true } = {}) {
|
|
36
|
+
const finalArgs = json ? [...args, '--output', 'json'] : args;
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
execFile(AZ, finalArgs, { timeout: AZ_TIMEOUT, maxBuffer: 64 * 1024 * 1024 },
|
|
39
|
+
(err, stdout, stderr) => {
|
|
40
|
+
if (err) {
|
|
41
|
+
const msg = (stderr || err.message || '').trim();
|
|
42
|
+
return reject(Object.assign(new Error(msg || 'az command failed'), {
|
|
43
|
+
azError: true,
|
|
44
|
+
needLogin: /AADSTS70043|refresh token|az login|InvalidAuthenticationToken|expired/i.test(msg),
|
|
45
|
+
forbidden: /Forbidden|does not have secrets|AccessDenied|not authorized|403/i.test(msg),
|
|
46
|
+
stderr: msg, args: finalArgs,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
if (!json) return resolve(stdout);
|
|
50
|
+
try { resolve(stdout.trim() ? JSON.parse(stdout) : null); }
|
|
51
|
+
catch (e) { reject(new Error('Failed to parse az output: ' + e.message)); }
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function send(res, p) {
|
|
57
|
+
p.then((data) => res.json({ ok: true, data }))
|
|
58
|
+
.catch((err) => res.status(err.needLogin ? 401 : err.forbidden ? 403 : 500).json({
|
|
59
|
+
ok: false, error: err.stderr || err.message,
|
|
60
|
+
needLogin: !!err.needLogin, forbidden: !!err.forbidden, firewall: !!err.firewall,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- simple in-memory cache for the expensive vault sweep ------------------
|
|
65
|
+
|
|
66
|
+
let vaultCache = { at: 0, data: null };
|
|
67
|
+
const VAULT_TTL = 60 * 1000;
|
|
68
|
+
|
|
69
|
+
// ---- Key Vault data plane over REST (fast path) ----------------------------
|
|
70
|
+
// Spawning `az` per lookup costs ~1-2s of Python startup each. Instead we grab a
|
|
71
|
+
// data-plane token once (via az), cache it, and call the vault's REST API directly
|
|
72
|
+
// with fetch — turning each lookup into a ~100-300ms HTTP request.
|
|
73
|
+
|
|
74
|
+
const KV_API = '7.4';
|
|
75
|
+
const VAULT_RES = 'https://vault.azure.net';
|
|
76
|
+
const ARM_RES = 'https://management.azure.com';
|
|
77
|
+
|
|
78
|
+
// One token per resource (ARM vs Key Vault have different audiences), cached.
|
|
79
|
+
// `az account get-access-token` is the ONLY remaining az call on the hot path —
|
|
80
|
+
// it bridges the existing az login (MFA / conditional access) into a bearer token;
|
|
81
|
+
// everything else below is plain fetch against Azure REST.
|
|
82
|
+
const tokenCache = new Map(); // resource -> { token, exp, pending }
|
|
83
|
+
|
|
84
|
+
async function getToken(resource) {
|
|
85
|
+
const c = tokenCache.get(resource);
|
|
86
|
+
if (c && c.token && Date.now() < c.exp) return c.token;
|
|
87
|
+
if (c && c.pending) return c.pending;
|
|
88
|
+
const pending = (async () => {
|
|
89
|
+
const out = await az(['account', 'get-access-token', '--resource', resource]);
|
|
90
|
+
tokenCache.set(resource, { token: out.accessToken, exp: Date.now() + 45 * 60 * 1000, pending: null });
|
|
91
|
+
return out.accessToken;
|
|
92
|
+
})();
|
|
93
|
+
tokenCache.set(resource, { ...(c || {}), pending });
|
|
94
|
+
try { return await pending; }
|
|
95
|
+
catch (e) { tokenCache.delete(resource); throw e; }
|
|
96
|
+
}
|
|
97
|
+
const getVaultToken = () => getToken(VAULT_RES);
|
|
98
|
+
const getArmToken = () => getToken(ARM_RES);
|
|
99
|
+
|
|
100
|
+
function decodeJwt(t) {
|
|
101
|
+
try {
|
|
102
|
+
const p = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
103
|
+
return JSON.parse(Buffer.from(p, 'base64').toString('utf8'));
|
|
104
|
+
} catch { return {}; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Azure Resource Manager REST call (management plane).
|
|
108
|
+
async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000 } = {}) {
|
|
109
|
+
const token = await getArmToken();
|
|
110
|
+
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${ARM_RES}${pathOrUrl}`;
|
|
111
|
+
const ctrl = new AbortController();
|
|
112
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
113
|
+
let resp;
|
|
114
|
+
try {
|
|
115
|
+
resp = await fetch(url, {
|
|
116
|
+
method,
|
|
117
|
+
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
118
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
119
|
+
signal: ctrl.signal,
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
throw Object.assign(new Error(e.name === 'AbortError' ? 'ARM request timed out' : e.message), { timedOut: e.name === 'AbortError' });
|
|
123
|
+
} finally { clearTimeout(timer); }
|
|
124
|
+
if (resp.status === 401) { tokenCache.delete(ARM_RES); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
|
|
125
|
+
if (resp.status === 403) throw Object.assign(new Error('Forbidden'), { forbidden: true });
|
|
126
|
+
if (!resp.ok) throw new Error(`ARM ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
127
|
+
return resp.json();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let subsCache = { at: 0, data: null };
|
|
131
|
+
const SUBS_TTL = 60 * 1000;
|
|
132
|
+
async function listSubscriptions(force) {
|
|
133
|
+
if (!force && subsCache.data && Date.now() - subsCache.at < SUBS_TTL) return subsCache.data;
|
|
134
|
+
const out = await armFetch('/subscriptions?api-version=2022-12-01');
|
|
135
|
+
const data = out.value || [];
|
|
136
|
+
subsCache = { at: Date.now(), data };
|
|
137
|
+
return data;
|
|
138
|
+
}
|
|
139
|
+
let selectedSub = null; // active subscription (server-side context, replaces `az account set`)
|
|
140
|
+
|
|
141
|
+
// Tell apart a real permission denial from a Key Vault firewall / network block.
|
|
142
|
+
async function forbiddenError(resp) {
|
|
143
|
+
let msg = 'Forbidden';
|
|
144
|
+
try { const j = await resp.json(); msg = (j.error && j.error.message) || msg; } catch { /* ignore */ }
|
|
145
|
+
const firewall = /Client address is not authorized|public network access is disabled|not allowed to access|trusted service/i.test(msg);
|
|
146
|
+
return Object.assign(new Error(msg), { forbidden: true, firewall });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function kvFetch(name, pathQuery, { timeout = 15000 } = {}) {
|
|
150
|
+
const token = await getVaultToken();
|
|
151
|
+
const ctrl = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
153
|
+
let resp;
|
|
154
|
+
try {
|
|
155
|
+
resp = await fetch(`https://${name.toLowerCase()}.vault.azure.net${pathQuery}`,
|
|
156
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal: ctrl.signal });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), { timedOut: e.name === 'AbortError' });
|
|
159
|
+
} finally { clearTimeout(timer); }
|
|
160
|
+
if (resp.status === 401) { tokenCache.delete(VAULT_RES);
|
|
161
|
+
throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
|
|
162
|
+
if (resp.status === 403) throw await forbiddenError(resp);
|
|
163
|
+
if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
|
|
164
|
+
if (resp.status === 429) throw Object.assign(new Error('Throttled'), { throttled: true });
|
|
165
|
+
if (!resp.ok) throw new Error(`Key Vault returned ${resp.status}`);
|
|
166
|
+
return resp.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Normalize a REST item to the shape the frontend/scan expect (az-like).
|
|
170
|
+
function normItem(it) {
|
|
171
|
+
const a = it.attributes || {};
|
|
172
|
+
return {
|
|
173
|
+
id: it.id,
|
|
174
|
+
name: (it.id || '').split('/').pop(),
|
|
175
|
+
managed: it.managed === true,
|
|
176
|
+
contentType: it.contentType,
|
|
177
|
+
attributes: {
|
|
178
|
+
enabled: a.enabled !== false,
|
|
179
|
+
expires: a.exp ? new Date(a.exp * 1000).toISOString() : null,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// List all items of a kind ('secrets'|'keys'|'certificates'), following paging.
|
|
185
|
+
async function kvList(name, kind, { maxPages = 400, timeout } = {}) {
|
|
186
|
+
let pathQuery = `/${kind}?api-version=${KV_API}&maxresults=25`;
|
|
187
|
+
const items = [];
|
|
188
|
+
for (let p = 0; p < maxPages; p++) {
|
|
189
|
+
const data = await kvFetch(name, pathQuery, { timeout });
|
|
190
|
+
for (const it of (data.value || [])) items.push(normItem(it));
|
|
191
|
+
if (!data.nextLink) break;
|
|
192
|
+
const u = new URL(data.nextLink);
|
|
193
|
+
pathQuery = u.pathname + u.search;
|
|
194
|
+
}
|
|
195
|
+
return items;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Short cache for list results so reopening a vault is instant.
|
|
199
|
+
const listCache = new Map(); // `${name}/${kind}` -> { at, data }
|
|
200
|
+
const LIST_TTL = 30 * 1000;
|
|
201
|
+
async function kvListCached(name, kind) {
|
|
202
|
+
const key = `${name}/${kind}`;
|
|
203
|
+
const hit = listCache.get(key);
|
|
204
|
+
if (hit && Date.now() - hit.at < LIST_TTL) return hit.data;
|
|
205
|
+
const data = await kvList(name, kind);
|
|
206
|
+
listCache.set(key, { at: Date.now(), data });
|
|
207
|
+
return data;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- API: accounts & subscriptions ----------------------------------------
|
|
211
|
+
|
|
212
|
+
app.get('/api/account', (req, res) =>
|
|
213
|
+
send(res, (async () => {
|
|
214
|
+
const claims = decodeJwt(await getArmToken());
|
|
215
|
+
const subs = await listSubscriptions();
|
|
216
|
+
if (!selectedSub && subs.length) selectedSub = subs[0].subscriptionId;
|
|
217
|
+
const cur = subs.find((s) => s.subscriptionId === selectedSub) || subs[0] || {};
|
|
218
|
+
return {
|
|
219
|
+
user: { name: claims.upn || claims.unique_name || claims.name || claims.email || 'signed in' },
|
|
220
|
+
name: cur.displayName || '', id: cur.subscriptionId || '',
|
|
221
|
+
tenantId: claims.tid || cur.tenantId || '',
|
|
222
|
+
};
|
|
223
|
+
})()));
|
|
224
|
+
|
|
225
|
+
app.get('/api/subscriptions', (req, res) =>
|
|
226
|
+
send(res, (async () => {
|
|
227
|
+
const subs = await listSubscriptions(!!req.query.refresh);
|
|
228
|
+
if (!selectedSub && subs.length) selectedSub = subs[0].subscriptionId;
|
|
229
|
+
return subs.map((s) => ({
|
|
230
|
+
id: s.subscriptionId, name: s.displayName, state: s.state,
|
|
231
|
+
tenantId: s.tenantId, isDefault: s.subscriptionId === selectedSub,
|
|
232
|
+
}));
|
|
233
|
+
})()));
|
|
234
|
+
|
|
235
|
+
app.post('/api/subscription/set', (req, res) => {
|
|
236
|
+
const { subscription } = req.body || {};
|
|
237
|
+
if (!subscription || !SUB_RE.test(subscription))
|
|
238
|
+
return res.status(400).json({ ok: false, error: 'Invalid subscription id/name' });
|
|
239
|
+
// With everything on REST, the "active subscription" is just server state.
|
|
240
|
+
send(res, (async () => {
|
|
241
|
+
const subs = await listSubscriptions();
|
|
242
|
+
const cur = subs.find((s) => s.subscriptionId === subscription);
|
|
243
|
+
if (!cur) throw new Error('Subscription not found');
|
|
244
|
+
selectedSub = subscription;
|
|
245
|
+
const claims = decodeJwt(await getArmToken());
|
|
246
|
+
return { user: { name: claims.upn || claims.unique_name || claims.name || 'signed in' },
|
|
247
|
+
name: cur.displayName, id: cur.subscriptionId, tenantId: claims.tid || cur.tenantId };
|
|
248
|
+
})());
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
app.post('/api/logout', (req, res) => {
|
|
252
|
+
tokenCache.clear(); subsCache = { at: 0, data: null }; vaultCache = { at: 0, data: null };
|
|
253
|
+
send(res, az(['logout'], { json: false }).then(() => ({ loggedOut: true })).catch(() => ({ loggedOut: true })));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---- API: Key Vault inventory (Resource Graph across all subscriptions) ----
|
|
257
|
+
|
|
258
|
+
const VAULT_QUERY =
|
|
259
|
+
"resources | where type =~ 'microsoft.keyvault/vaults' " +
|
|
260
|
+
"| project name, resourceGroup, subscriptionId, location, " +
|
|
261
|
+
"sku=tostring(properties.sku.name), " +
|
|
262
|
+
"enableRbac=tostring(properties.enableRbacAuthorization), " +
|
|
263
|
+
"uri=tostring(properties.vaultUri) | order by name asc";
|
|
264
|
+
|
|
265
|
+
async function listVaultsRaw(force) {
|
|
266
|
+
if (!force && vaultCache.data && Date.now() - vaultCache.at < VAULT_TTL) return vaultCache.data;
|
|
267
|
+
const subIds = (await listSubscriptions(force)).map((s) => s.subscriptionId);
|
|
268
|
+
const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
body: { subscriptions: subIds, query: VAULT_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
|
|
271
|
+
});
|
|
272
|
+
const data = out.data || [];
|
|
273
|
+
vaultCache = { at: Date.now(), data };
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
app.get('/api/vaults', async (req, res) => {
|
|
278
|
+
try {
|
|
279
|
+
const data = await listVaultsRaw(!!req.query.refresh);
|
|
280
|
+
res.json({ ok: true, data });
|
|
281
|
+
} catch (err) {
|
|
282
|
+
res.status(err.needLogin ? 401 : 500)
|
|
283
|
+
.json({ ok: false, error: err.stderr || err.message, needLogin: !!err.needLogin });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ---- API: expiring secrets & certificates (scans every readable vault) -----
|
|
288
|
+
|
|
289
|
+
/** Run async worker over items with bounded concurrency. */
|
|
290
|
+
function pool(items, worker, concurrency = 14) {
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
const out = []; let i = 0, active = 0, finished = 0;
|
|
293
|
+
if (!items.length) return resolve(out);
|
|
294
|
+
const launch = () => {
|
|
295
|
+
while (active < concurrency && i < items.length) {
|
|
296
|
+
const idx = i++; active++;
|
|
297
|
+
Promise.resolve(worker(items[idx], idx))
|
|
298
|
+
.then((r) => { out[idx] = r; })
|
|
299
|
+
.catch(() => { out[idx] = null; })
|
|
300
|
+
.finally(() => {
|
|
301
|
+
active--; finished++;
|
|
302
|
+
if (finished === items.length) resolve(out); else launch();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
launch();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function summarizeExpiring(items, days) {
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
const horizon = now + days * 86400000;
|
|
313
|
+
return items
|
|
314
|
+
.map((it) => ({ ...it, daysLeft: Math.floor((Date.parse(it.expires) - now) / 86400000) }))
|
|
315
|
+
.filter((it) => Date.parse(it.expires) <= horizon) // expired (negative) + expiring soon
|
|
316
|
+
.sort((a, b) => a.daysLeft - b.daysLeft);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Background scan job (scanning hundreds of vaults is too slow for one request).
|
|
320
|
+
let scanJob = { running: false, done: 0, total: 0, startedAt: 0, finishedAt: 0,
|
|
321
|
+
items: [], stats: null, error: null };
|
|
322
|
+
|
|
323
|
+
async function runScan(vaults) {
|
|
324
|
+
const items = [];
|
|
325
|
+
const stats = { total: vaults.length, readable: 0, forbidden: 0, errored: 0 };
|
|
326
|
+
scanJob = { running: true, done: 0, total: vaults.length, startedAt: Date.now(),
|
|
327
|
+
finishedAt: 0, items, stats, error: null };
|
|
328
|
+
|
|
329
|
+
const collect = async (vault, kind, type) => {
|
|
330
|
+
try {
|
|
331
|
+
const list = await kvList(vault.name, kind, { maxPages: 400, timeout: 20000 });
|
|
332
|
+
for (const it of list) {
|
|
333
|
+
if (it.managed && type === 'secret') continue; // skip cert-backing secrets
|
|
334
|
+
const exp = it.attributes && it.attributes.expires;
|
|
335
|
+
if (!exp) continue;
|
|
336
|
+
items.push({
|
|
337
|
+
vault: vault.name, resourceGroup: vault.resourceGroup, subscriptionId: vault.subscriptionId,
|
|
338
|
+
type, name: it.name, expires: exp, enabled: it.attributes.enabled,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return 'ok';
|
|
342
|
+
} catch (e) { return e.forbidden ? 'forbidden' : 'error'; }
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
await pool(vaults, async (v) => {
|
|
346
|
+
// Secrets first; if the vault denies data-plane access, certs will too — skip the call.
|
|
347
|
+
const s = await collect(v, 'secrets', 'secret');
|
|
348
|
+
const c = s === 'forbidden' ? 'forbidden' : await collect(v, 'certificates', 'certificate');
|
|
349
|
+
if (s === 'ok' || c === 'ok') stats.readable++;
|
|
350
|
+
else if (s === 'forbidden') stats.forbidden++;
|
|
351
|
+
else stats.errored++;
|
|
352
|
+
scanJob.done++;
|
|
353
|
+
}, 24);
|
|
354
|
+
|
|
355
|
+
scanJob.running = false;
|
|
356
|
+
scanJob.finishedAt = Date.now();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
app.post('/api/expiring/scan', async (req, res) => {
|
|
360
|
+
if (scanJob.running) return res.json({ ok: true, running: true, done: scanJob.done, total: scanJob.total });
|
|
361
|
+
let vaults;
|
|
362
|
+
try { vaults = await listVaultsRaw(); }
|
|
363
|
+
catch (err) {
|
|
364
|
+
return res.status(err.needLogin ? 401 : 500)
|
|
365
|
+
.json({ ok: false, error: err.stderr || err.message, needLogin: !!err.needLogin });
|
|
366
|
+
}
|
|
367
|
+
runScan(vaults).catch((e) => { scanJob.running = false; scanJob.error = e.message; });
|
|
368
|
+
res.json({ ok: true, running: true, done: 0, total: vaults.length });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
app.get('/api/expiring/status', (req, res) => {
|
|
372
|
+
const days = Math.min(3650, Math.max(0, Number(req.query.days) || 30));
|
|
373
|
+
res.json({
|
|
374
|
+
ok: true, running: scanJob.running, done: scanJob.done, total: scanJob.total,
|
|
375
|
+
scannedAt: scanJob.finishedAt, stats: scanJob.stats, error: scanJob.error,
|
|
376
|
+
items: scanJob.finishedAt ? summarizeExpiring(scanJob.items, days) : [],
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ---- API: per-vault data-plane (secrets / keys / certificates) -------------
|
|
381
|
+
|
|
382
|
+
for (const kind of ['secrets', 'keys', 'certificates']) {
|
|
383
|
+
app.get(`/api/vaults/:name/${kind}`, (req, res) => {
|
|
384
|
+
const { name } = req.params;
|
|
385
|
+
if (!isValidName(name))
|
|
386
|
+
return res.status(400).json({ ok: false, error: 'Invalid vault name' });
|
|
387
|
+
send(res, kvListCached(name, kind));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Reveal a single secret value (explicit, sensitive action — never cached).
|
|
392
|
+
app.get('/api/vaults/:name/secrets/:secret/value', (req, res) => {
|
|
393
|
+
const { name, secret } = req.params;
|
|
394
|
+
if (!isValidName(name) || !isValidName(secret))
|
|
395
|
+
return res.status(400).json({ ok: false, error: 'Invalid name' });
|
|
396
|
+
// REST returns { value, id, attributes, ... } — same `.value` the UI reads.
|
|
397
|
+
send(res, kvFetch(name, `/secrets/${secret}?api-version=${KV_API}`));
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ---- API: write operations (update secrets / certificates) -----------------
|
|
401
|
+
|
|
402
|
+
function invalidateVault(name) {
|
|
403
|
+
for (const k of listCache.keys()) if (k.startsWith(name + '/')) listCache.delete(k);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Build a Key Vault attributes object from { enabled, expires } input.
|
|
407
|
+
function buildAttrs(src = {}) {
|
|
408
|
+
const a = {};
|
|
409
|
+
if (typeof src.enabled === 'boolean') a.enabled = src.enabled;
|
|
410
|
+
if (src.expires) {
|
|
411
|
+
const t = Date.parse(src.expires);
|
|
412
|
+
if (!isNaN(t)) a.exp = Math.floor(t / 1000); // KV wants unix seconds
|
|
413
|
+
}
|
|
414
|
+
return a;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function kvWrite(vaultName, method, pathQuery, body) {
|
|
418
|
+
const token = await getVaultToken();
|
|
419
|
+
const ctrl = new AbortController();
|
|
420
|
+
const timer = setTimeout(() => ctrl.abort(), 20000);
|
|
421
|
+
let resp;
|
|
422
|
+
try {
|
|
423
|
+
resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, {
|
|
424
|
+
method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
425
|
+
body: JSON.stringify(body), signal: ctrl.signal,
|
|
426
|
+
});
|
|
427
|
+
} catch (e) {
|
|
428
|
+
throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), {});
|
|
429
|
+
} finally { clearTimeout(timer); }
|
|
430
|
+
if (resp.status === 401) { tokenCache.delete(VAULT_RES); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
|
|
431
|
+
if (resp.status === 403) throw await forbiddenError(resp);
|
|
432
|
+
if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
|
|
433
|
+
if (!resp.ok) {
|
|
434
|
+
let m = `Key Vault returned ${resp.status}`;
|
|
435
|
+
try { const j = await resp.json(); m = (j.error && j.error.message) || m; } catch { /* ignore */ }
|
|
436
|
+
throw new Error(m);
|
|
437
|
+
}
|
|
438
|
+
invalidateVault(vaultName);
|
|
439
|
+
return resp.json();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Update a secret's attributes (enabled / expires) on its current version.
|
|
443
|
+
app.patch('/api/vaults/:name/secrets/:secret', (req, res) => {
|
|
444
|
+
const { name, secret } = req.params;
|
|
445
|
+
if (!isValidName(name) || !isValidName(secret)) return res.status(400).json({ ok: false, error: 'Invalid name' });
|
|
446
|
+
send(res, kvWrite(name, 'PATCH', `/secrets/${secret}?api-version=${KV_API}`, { attributes: buildAttrs(req.body) }));
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Set a secret's value (creates a new version), optionally with attributes.
|
|
450
|
+
app.put('/api/vaults/:name/secrets/:secret', (req, res) => {
|
|
451
|
+
const { name, secret } = req.params;
|
|
452
|
+
if (!isValidName(name) || !isValidName(secret)) return res.status(400).json({ ok: false, error: 'Invalid name' });
|
|
453
|
+
const { value } = req.body || {};
|
|
454
|
+
if (typeof value !== 'string' || !value.length) return res.status(400).json({ ok: false, error: 'A non-empty value is required' });
|
|
455
|
+
const body = { value };
|
|
456
|
+
const attrs = buildAttrs(req.body);
|
|
457
|
+
if (Object.keys(attrs).length) body.attributes = attrs;
|
|
458
|
+
send(res, kvWrite(name, 'PUT', `/secrets/${secret}?api-version=${KV_API}`, body));
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Update a certificate's attributes (enabled only — expiry is intrinsic to the cert).
|
|
462
|
+
app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
|
|
463
|
+
const { name, cert } = req.params;
|
|
464
|
+
if (!isValidName(name) || !isValidName(cert)) return res.status(400).json({ ok: false, error: 'Invalid name' });
|
|
465
|
+
const attrs = {};
|
|
466
|
+
if (typeof req.body.enabled === 'boolean') attrs.enabled = req.body.enabled;
|
|
467
|
+
send(res, kvWrite(name, 'PATCH', `/certificates/${cert}?api-version=${KV_API}`, { attributes: attrs }));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Create one or many secrets in a vault. items: [{name, value}], optional common expires.
|
|
471
|
+
app.post('/api/vaults/:name/secrets-create', async (req, res) => {
|
|
472
|
+
const { name } = req.params;
|
|
473
|
+
if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
|
|
474
|
+
const { items, expires } = req.body || {};
|
|
475
|
+
if (!Array.isArray(items) || !items.length) return res.status(400).json({ ok: false, error: 'No secrets to create' });
|
|
476
|
+
if (items.length > 500) return res.status(400).json({ ok: false, error: 'Too many (max 500)' });
|
|
477
|
+
const attrs = buildAttrs({ expires });
|
|
478
|
+
const results = await pool(items, async (it) => {
|
|
479
|
+
const r = { name: it.name };
|
|
480
|
+
try {
|
|
481
|
+
if (!isValidName(it.name)) throw new Error('Invalid name (use letters, digits, hyphen)');
|
|
482
|
+
if (typeof it.value !== 'string' || !it.value.length) throw new Error('Empty value');
|
|
483
|
+
const body = { value: it.value };
|
|
484
|
+
if (Object.keys(attrs).length) body.attributes = attrs;
|
|
485
|
+
await kvWrite(name, 'PUT', `/secrets/${it.name}?api-version=${KV_API}`, body);
|
|
486
|
+
return { ...r, ok: true };
|
|
487
|
+
} catch (e) { return { ...r, ok: false, error: e.forbidden ? 'forbidden' : (e.message || 'failed') }; }
|
|
488
|
+
}, 10);
|
|
489
|
+
res.json({ ok: true, results });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Batch update across one or many vaults. items: [{vault, kind, name}], op: {enabled?, expires?}
|
|
493
|
+
app.post('/api/batch', async (req, res) => {
|
|
494
|
+
const { op = {}, items } = req.body || {};
|
|
495
|
+
if (!Array.isArray(items) || !items.length) return res.status(400).json({ ok: false, error: 'No items selected' });
|
|
496
|
+
if (items.length > 500) return res.status(400).json({ ok: false, error: 'Too many items (max 500 per batch)' });
|
|
497
|
+
const attrs = buildAttrs(op);
|
|
498
|
+
const results = await pool(items, async (it) => {
|
|
499
|
+
const r = { vault: it.vault, name: it.name, kind: it.kind };
|
|
500
|
+
try {
|
|
501
|
+
if (!isValidName(it.vault) || !isValidName(it.name)) throw new Error('Invalid name');
|
|
502
|
+
if (it.kind === 'certificate') {
|
|
503
|
+
if (op.expires) throw new Error("A certificate's expiry is fixed by the certificate itself");
|
|
504
|
+
const a = {}; if (typeof op.enabled === 'boolean') a.enabled = op.enabled;
|
|
505
|
+
if (!Object.keys(a).length) throw new Error('Nothing to change');
|
|
506
|
+
await kvWrite(it.vault, 'PATCH', `/certificates/${it.name}?api-version=${KV_API}`, { attributes: a });
|
|
507
|
+
} else {
|
|
508
|
+
if (!Object.keys(attrs).length) throw new Error('Nothing to change');
|
|
509
|
+
await kvWrite(it.vault, 'PATCH', `/secrets/${it.name}?api-version=${KV_API}`, { attributes: attrs });
|
|
510
|
+
}
|
|
511
|
+
return { ...r, ok: true };
|
|
512
|
+
} catch (e) { return { ...r, ok: false, error: e.forbidden ? 'forbidden' : (e.message || 'failed') }; }
|
|
513
|
+
}, 12);
|
|
514
|
+
res.json({ ok: true, results });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ---- fallback --------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
app.get('/api/health', (req, res) => res.json({ ok: true, az: AZ }));
|
|
520
|
+
|
|
521
|
+
// Optional, org-specific UI config — supplied by the operator via env vars, never baked in.
|
|
522
|
+
// AZBO_ENV_RULES: JSON like [["dev","Dev"],["prd","Prod"]] mapping name patterns to labels.
|
|
523
|
+
const ENV_RULES = (() => {
|
|
524
|
+
try { return process.env.AZBO_ENV_RULES ? JSON.parse(process.env.AZBO_ENV_RULES) : null; }
|
|
525
|
+
catch { console.warn(' AZBO_ENV_RULES is not valid JSON — ignoring.'); return null; }
|
|
526
|
+
})();
|
|
527
|
+
app.get('/api/config', (req, res) =>
|
|
528
|
+
res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null } }));
|
|
529
|
+
|
|
530
|
+
function openBrowser(url) {
|
|
531
|
+
if (process.env.AZBO_NO_OPEN) return;
|
|
532
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
533
|
+
try { require('child_process').spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref(); }
|
|
534
|
+
catch { /* opening is best-effort */ }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function start(port, attemptsLeft = 12) {
|
|
538
|
+
const server = app.listen(port, HOST, () => {
|
|
539
|
+
const url = `http://${HOST}:${port}`;
|
|
540
|
+
console.log(`\n akv-manager → ${url}`);
|
|
541
|
+
console.log(` Auth: your current 'az login' identity (run 'az login' if signed out).`);
|
|
542
|
+
console.log(` Stop with Ctrl-C.\n`);
|
|
543
|
+
openBrowser(url);
|
|
544
|
+
});
|
|
545
|
+
server.on('error', (e) => {
|
|
546
|
+
if (e.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
547
|
+
console.log(` Port ${port} in use, trying ${port + 1}…`);
|
|
548
|
+
start(port + 1, attemptsLeft - 1);
|
|
549
|
+
} else { console.error(` Failed to start: ${e.message}`); process.exit(1); }
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
start(PORT);
|