From 3e520918a75e2184b5d9e0e4116455a126995a24 Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 00:01:11 +0000 Subject: [PATCH] Upload files to "scripts" --- scripts/build-plugin.ts | 82 ++++++++++++++ scripts/build.ts | 136 +++++++++++++++++++++++ scripts/decrypt.ts | 235 ++++++++++++++++++++++++++++++++++++++++ scripts/env-scramble.ts | 43 ++++++++ scripts/obfuscate.js | 11 ++ 5 files changed, 507 insertions(+) create mode 100644 scripts/build-plugin.ts create mode 100644 scripts/build.ts create mode 100644 scripts/decrypt.ts create mode 100644 scripts/env-scramble.ts create mode 100644 scripts/obfuscate.js diff --git a/scripts/build-plugin.ts b/scripts/build-plugin.ts new file mode 100644 index 0000000..ba2fd39 --- /dev/null +++ b/scripts/build-plugin.ts @@ -0,0 +1,82 @@ +import { plugin } from "bun"; + +import { + generateBuildPassphrase, + RUNTIME_PASSPHRASE_PLACEHOLDER, + transformSource, +} from "./scramble-shared"; + +// Provide a global identity stub for scramble() so that un-transformed +// source modules can be safely evaluated when we import StringScrambler +// from the source tree. +(globalThis as any).scramble = (s: string) => s; + +// Dynamic import — MUST come after the stub is installed. +const { StringScrambler } = await import("../src/utils/stringtool"); + +const PASSPHRASE = generateBuildPassphrase(); +console.log( + `[SCRAMBLE] Generated build passphrase (${PASSPHRASE.length} chars)`, +); + +const scrambler = new StringScrambler(PASSPHRASE); + +const RUNTIME_DECODER_FILE_REGEX = /[\\/]src[\\/]utils[\\/]runtimeDecoder\.ts$/; +const ENTRYPOINT_FILE_REGEX = /[\\/]src[\\/]index\.ts$/; +const QUOTED_PLACEHOLDER = `"${RUNTIME_PASSPHRASE_PLACEHOLDER}"`; + +plugin({ + name: "scramble", + setup(build) { + build.onLoad({ filter: /\.ts$/ }, async (args) => { + let code = await Bun.file(args.path).text(); + + if (RUNTIME_DECODER_FILE_REGEX.test(args.path)) { + if (!code.includes(QUOTED_PLACEHOLDER)) { + throw new Error( + `[SCRAMBLE] runtime decoder ${args.path} does not contain the ` + + `expected placeholder ${QUOTED_PLACEHOLDER}. The build ` + + `pipeline cannot inject a passphrase, which would lead to ` + + `garbled strings at runtime.`, + ); + } + + const literal = JSON.stringify(PASSPHRASE); + code = code.split(QUOTED_PLACEHOLDER).join(literal); + console.log(`[SCRAMBLE] Injected build passphrase into ${args.path}`); + + return { + contents: code, + loader: "ts", + }; + } + + if (ENTRYPOINT_FILE_REGEX.test(args.path)) { + console.log( + `[SCRAMBLE] Prepending runtime decoder import to ${args.path}`, + ); + code = `import "./utils/runtimeDecoder";\n${code}`; + } + + console.log(`[SCRAMBLE] Processing: ${args.path}`); + + const { code: transformed, replacements } = transformSource( + code, + scrambler, + "[SCRAMBLE]", + args.path, + ); + + if (replacements > 0) { + console.log( + `[SCRAMBLE] Encoded ${replacements} call(s) in ${args.path}`, + ); + } + + return { + contents: transformed, + loader: "ts", + }; + }); + }, +}); diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..483d744 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,136 @@ +import { promises as fs } from "fs"; +import * as path from "path"; + +import { transformEnvAccess } from "./env-scramble"; +import { + generateBuildPassphrase, + rewriteRuntimeDecoder, + RUNTIME_DECODER_PATH, + transformSource, +} from "./scramble-shared"; +import { stripLogCalls } from "./strip-logs"; + +(globalThis as any).scramble = (s: string) => s; + +const { StringScrambler } = await import("../src/utils/stringtool"); + +const PASSPHRASE = generateBuildPassphrase(); +console.log(`[BUILD] Generated build passphrase (${PASSPHRASE.length} chars)`); + +const scrambler = new StringScrambler(PASSPHRASE); + +// Read isSilent from the source of truth — logger.ts itself. +const loggerSource = await fs.readFile("src/utils/logger.ts", "utf-8"); +const isSilent = /const\s+isSilent\s*=\s*true/.test(loggerSource); +console.log(`[BUILD] isSilent = ${isSilent}`); + +async function transformFile(filePath: string): Promise { + const code = await fs.readFile(filePath, "utf-8"); + + console.log(`[TRANSFORM] Processing: ${filePath}`); + + // 1. Rewrite process.env.XYZ -> process.env[scramble("XYZ")] + const { code: envRewritten } = transformEnvAccess( + code, + "[TRANSFORM]", + filePath, + ); + + // 2. Scramble transform (encodes all scramble("...") calls) + const { code: scrambled } = transformSource( + envRewritten, + scrambler, + "[TRANSFORM]", + filePath, + ); + + // 3. Strip logUtil calls (only when isSilent is true) + if (isSilent) { + const { code: stripped } = stripLogCalls( + scrambled, + "[TRANSFORM]", + filePath, + ); + return stripped; + } + + return scrambled; +} + +async function walkDir(dir: string): Promise { + const files: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkDir(fullPath))); + } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { + files.push(fullPath); + } + } + + return files; +} + +async function build() { + console.log("[BUILD] Setting up temp directory..."); + + const tempDir = "./.bun-temp"; + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.mkdir(tempDir, { recursive: true }); + + console.log("[BUILD] Copying and transforming source files..."); + + const files = await walkDir("./src"); + console.log(`[BUILD] Processing ${files.length} TypeScript files`); + + for (const file of files) { + const transformed = await transformFile(file); + const tempFile = path.join(tempDir, path.relative("./src", file)); + await fs.mkdir(path.dirname(tempFile), { recursive: true }); + await fs.writeFile(tempFile, transformed, "utf-8"); + } + + const tempDecoderPath = path.join( + tempDir, + path.relative("./src", path.resolve(RUNTIME_DECODER_PATH)), + ); + const rewrittenDecoder = await rewriteRuntimeDecoder( + RUNTIME_DECODER_PATH, + PASSPHRASE, + ); + await fs.mkdir(path.dirname(tempDecoderPath), { recursive: true }); + await fs.writeFile(tempDecoderPath, rewrittenDecoder, "utf-8"); + console.log( + `[BUILD] Injected build passphrase into ${path.relative(tempDir, tempDecoderPath)}`, + ); + + const indexPath = path.join(tempDir, "index.ts"); + + const indexCode = await fs.readFile(indexPath, "utf-8"); + await fs.writeFile( + indexPath, + `import "./utils/runtimeDecoder";\n${indexCode}`, + "utf-8", + ); + + console.log("[BUILD] Running Bun build on transformed sources..."); + + await Bun.build({ + entrypoints: [indexPath], + outdir: "./dist", + naming: { + entry: "bundle.js", + }, + target: "bun", + minify: true, + }); + + console.log("[BUILD] Cleaning up temp directory..."); + await fs.rm(tempDir, { recursive: true, force: true }); + + console.log("[BUILD] ✓ Build complete!"); +} + +build().catch(console.error); diff --git a/scripts/decrypt.ts b/scripts/decrypt.ts new file mode 100644 index 0000000..965fb43 --- /dev/null +++ b/scripts/decrypt.ts @@ -0,0 +1,235 @@ +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; + const privateKeyPem = fs.readFileSync(privateKeyPath, "utf-8"); + const fileEntries = resolveJsonPaths(encryptedJsonPath); + const multiple = fileEntries.length > 1; + + let failures = 0; + + 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, + ); + + if (multiple) { + console.log(`\n--- ${entry.label} ---`); + } + console.log(JSON.stringify(decrypted, null, 2)); + } catch (error) { + failures++; + console.error( + `Error${multiple ? ` (${entry.label})` : ""}:`, + error instanceof Error ? error.message : String(error), + ); + } + } + + 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/obfuscate.js b/scripts/obfuscate.js new file mode 100644 index 0000000..2fda56c --- /dev/null +++ b/scripts/obfuscate.js @@ -0,0 +1,11 @@ +import JavaScriptObfuscator from "javascript-obfuscator"; + +const code = await Bun.file("./dist/bundle.js").text(); +const obfuscated = JavaScriptObfuscator.obfuscate(code, { + compact: true, + controlFlowFlattening: true, + stringArray: true, + stringArrayEncoding: ["base64"], +}).getObfuscatedCode(); + +await Bun.write("./dist/bundle_obf.js", obfuscated);