Upload files to "scripts"
This commit is contained in:
@@ -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",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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<string[]> {
|
||||||
|
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);
|
||||||
@@ -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<string, string[]>();
|
||||||
|
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<unknown> {
|
||||||
|
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<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length < 2) {
|
||||||
|
console.error(
|
||||||
|
"Usage: ts-node decrypt.ts <private-key-path> <encrypted-json-path-or-dir>",
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user