UNPKG

jscrambler-metro-plugin

Version:

A plugin to use metro with Jscrambler Code Integrity

378 lines (327 loc) 12.3 kB
const {copy, emptyDir, mkdirp, readFile, writeFile} = require('fs-extra'); const jscrambler = require('jscrambler').default; const fs = require('fs'); const path = require('path'); const generateSourceMaps = require('./sourceMaps'); const globalThisPolyfill = require('./polyfills/globalThis'); const { INIT_CORE_MODULE, JSCRAMBLER_CLIENT_ID, JSCRAMBLER_TEMP_FOLDER, JSCRAMBLER_IGNORE, JSCRAMBLER_DIST_TEMP_FOLDER, JSCRAMBLER_PROTECTION_ID_FILE, JSCRAMBLER_BEG_ANNOTATION, JSCRAMBLER_END_ANNOTATION, BUNDLE_SOURCEMAP_OUTPUT_CLI_ARG, HERMES_SHOW_SOURCE_DIRECTIVE, JSCRAMBLER_EXTS } = require('./constants'); const { buildModuleSourceMap, buildNormalizePath, extractLocs, getBundlePath, isFileReadable, skipObfuscation, stripEntryPointTags, stripJscramblerTags, addBundleArgsToExcludeList, handleExcludeList, injectTolerateBegninPoisoning, handleAntiTampering, addHermesShowSourceDirective, handleHermesIncompatibilities, wrapCodeWithTags } = require('./utils'); const debug = !!process.env.DEBUG; function logSourceMapsWarning(hasMetroSourceMaps, hasJscramblerSourceMaps) { if (hasMetroSourceMaps) { console.log(`warning: Jscrambler source-maps are DISABLED. Check how to activate them in https://docs.jscrambler.com/code-integrity/documentation/source-maps/api`); } else if (hasJscramblerSourceMaps) { console.log(`warning: Jscrambler source-maps were not generated. Missing metro source-maps (${BUNDLE_SOURCEMAP_OUTPUT_CLI_ARG} is required)`); } } async function obfuscateBundle( {bundlePath, bundleSourceMapPath}, {fileNames, entryPointCode}, sourceMapFiles, config, projectRoot ) { await emptyDir(JSCRAMBLER_TEMP_FOLDER); const metroBundle = await readFile(bundlePath, 'utf8'); const metroBundleLocs = await extractLocs(metroBundle); let processedMetroBundle = metroBundle; let filteredFileNames = fileNames; const excludeList = []; const supportsEntryPoint = await jscrambler.introspectFieldOnMethod.call( jscrambler, config, "mutation", "createApplicationProtection", "entryPoint" ); // ignore entrypoint obfuscation if its not supported if (!supportsEntryPoint) { delete config.entryPoint; if (typeof entryPointCode === 'string' && entryPointCode.length > 0) { debug && console.log('debug Jscrambler entrypoint option not supported'); try { filteredFileNames = fileNames.filter( name => !name.includes(INIT_CORE_MODULE) ); processedMetroBundle = stripEntryPointTags( metroBundle, entryPointCode ); } catch (err) { console.log("Error processing entry point."); process.exit(-1); } } } const metroBundleChunks = processedMetroBundle.split( JSCRAMBLER_BEG_ANNOTATION ); addBundleArgsToExcludeList(metroBundleChunks[0], excludeList); const metroUserFilesOnly = metroBundleChunks.slice(1).map((c, i) => { const s = c.split(JSCRAMBLER_END_ANNOTATION); // We don't want to extract args from last chunk if (i < metroBundleChunks.length - 2) { addBundleArgsToExcludeList(s[1], excludeList); } return s[0]; }); const sources = []; // .jscramblerignore const defaultJscramblerIgnorePath = path.join(projectRoot, JSCRAMBLER_IGNORE); if (typeof config.ignoreFile === 'string') { if (!await isFileReadable(config.ignoreFile)) { console.error(`The *ignoreFile* "${config.ignoreFile}" was not found or is not readable!`); process.exit(-1); } sources.push({ filename: JSCRAMBLER_IGNORE, content: await readFile(config.ignoreFile) }) } else if (await isFileReadable(defaultJscramblerIgnorePath)) { sources.push({ filename: JSCRAMBLER_IGNORE, content: await readFile(defaultJscramblerIgnorePath) }) } // push user files to sources array for (let i = 0; i < metroUserFilesOnly.length; i += 1) { sources.push({ filename: filteredFileNames[i], content: metroUserFilesOnly[i] }) } // Source map files (only for Instrumentation process) for (const { filename, content } of sourceMapFiles) { sources.push({ filename, content }) } // adapt configs for react-native config.sources = sources; config.filesDest = JSCRAMBLER_DIST_TEMP_FOLDER; config.clientId = JSCRAMBLER_CLIENT_ID; const supportsExcludeList = await jscrambler.introspectFieldOnMethod.call( jscrambler, config, "mutation", "createApplicationProtection", "excludeList" ); handleExcludeList(config, {supportsExcludeList, excludeList}); injectTolerateBegninPoisoning(config); if (bundleSourceMapPath && typeof config.sourceMaps === 'undefined') { console.error(`error Metro is generating source maps that won't be useful after Jscrambler protection. If this is not a problem, you can either: 1) Disable source maps in metro bundler 2) Explicitly disable Jscrambler source maps by adding 'sourceMaps: false' in the Jscrambler config file If you want valid source maps, make sure you have access to the feature and enable it in Jscrambler config file by adding 'sourceMaps: true'` ); process.exit(-1); } const requireStartAtFirstColumn = handleAntiTampering( config, processedMetroBundle, ); const addShowSource = addHermesShowSourceDirective(config); if (addShowSource) { console.log( `info Jscrambler ${HERMES_SHOW_SOURCE_DIRECTIVE} directive added`, ); } const shouldGenerateSourceMaps = config.sourceMaps && bundleSourceMapPath; const jscramblerOp = !!config.instrument ? jscrambler.instrumentAndDownload : jscrambler.protectAndDownload; // obfuscate or instrument const protectionId = await jscramblerOp.call(jscrambler, config); // store protection id await writeFile(JSCRAMBLER_PROTECTION_ID_FILE, protectionId); // read obfuscated user files const obfusctedUserFiles = await Promise.all(metroUserFilesOnly.map((c, i) => readFile(`${JSCRAMBLER_DIST_TEMP_FOLDER}/${filteredFileNames[i]}`, 'utf8') )); // build final bundle (with JSCRAMBLER TAGS still) const finalBundle = metroBundleChunks.reduce((acc, c, i) => { if (i === 0) { const chunks = c.split('\n'); return [`${chunks[0]}${globalThisPolyfill}`, ...chunks.slice(1)].join('\n'); } let showSource = addShowSource; let startAtFirstColumn = requireStartAtFirstColumn; const obfuscatedCode = obfusctedUserFiles[i - 1]; const sourceFileIgnored = metroUserFilesOnly[i - 1] === obfuscatedCode; if (sourceFileIgnored) { // restore excluded files showSource = false; startAtFirstColumn = false; debug && console.log(`debug Jscrambler File ${fileNames[i - 1]} was excluded`); } const tillCodeEnd = c.substr( c.indexOf(JSCRAMBLER_END_ANNOTATION), c.length ); return `${acc}${JSCRAMBLER_BEG_ANNOTATION}${ showSource ? HERMES_SHOW_SOURCE_DIRECTIVE : '' }${startAtFirstColumn ? '\n' : ''}${obfuscatedCode}${tillCodeEnd}`; }, ''); await writeFile(bundlePath, stripJscramblerTags(finalBundle)); if(!shouldGenerateSourceMaps) { logSourceMapsWarning(bundleSourceMapPath, config.sourceMaps); // nothing more to do return; } // process Jscrambler SourceMaps const shouldAddSourceContent = typeof config.sourceMaps === 'object' ? config.sourceMaps.sourceContent : false; console.log(`info Jscrambler Source Maps (${shouldAddSourceContent ? "with" : "no"} source content)`); const finalSourceMap = await generateSourceMaps({ jscrambler, config, shouldAddSourceContent, protectionId, metroUserFilesOnly, fileNames: filteredFileNames, bundlePath, bundleSourceMapPath, finalBundle, projectRoot, debug, metroBundleLocs }); await writeFile(bundleSourceMapPath, finalSourceMap); } function fileExists(modulePath) { return fs.existsSync(modulePath); } function isValidExtension(modulePath) { return path.extname(modulePath).match(JSCRAMBLER_EXTS); } function validateModule(modulePath, config, projectRoot) { const instrument = !!config.instrument; if ( !fileExists(modulePath) || !isValidExtension(modulePath) || typeof modulePath !== "string" ) { return false; } else if (modulePath.includes(INIT_CORE_MODULE) && !instrument) { // This is the entrypoint file config.entryPoint = buildNormalizePath(modulePath, projectRoot); return true; } else if (modulePath.includes("node_modules")) { return false; } else { return true; } } /** * Add serialize.processModuleFilter option to metro and attach listener to beforeExit event. * *config.fileSrc* and *config.filesDest* will be ignored. * @param {{enable: boolean, enabledHermes: boolean }} _config * @param {string} [projectRoot=process.cwd()] * @returns {{serializer: {processModuleFilter(*): boolean}}} */ module.exports = function (_config = {}, projectRoot = process.cwd()) { const skipReason = skipObfuscation(_config); if (skipReason) { console.log(`warning: Jscrambler Obfuscation SKIPPED [${skipReason}]`); return {}; } const bundlePath = getBundlePath(); // make sure jscrambler-metro-plugin is properly configure on metro bundler let calledByMetro = false; const fileNames = new Set(); const sourceMapFiles = []; const config = Object.assign({}, jscrambler.config, _config); const instrument = !!config.instrument; let entryPointCode; if (config.filesDest || config.filesSrc) { console.warn('warning: Jscrambler fields filesDest and fileSrc were ignored. Using input/output values of the metro bundler.') } if (!Array.isArray(config.params) || config.params.length === 0) { console.warn('warning: Jscrambler recommends you to declare your transformations list on the configuration file.') } process.on('beforeExit', async function (exitCode) { try{ if (!calledByMetro) { throw new Error('*jscrambler-metro-plugin* was not properly configured on metro.config.js file. Please verify our documentation in https://docs.jscrambler.com/code-integrity/frameworks-and-libraries/react-native/integration.'); } console.log( instrument ? 'info Jscrambler Instrumenting Code' : `info Jscrambler Obfuscating Code ${ config.enabledHermes ? "(Using Hermes Engine)" : "(If you are using Hermes Engine set enabledHermes=true)" }`, ); // check for incompatible transformations and turn off code hardening handleHermesIncompatibilities(config); // start obfuscation await obfuscateBundle(bundlePath, {fileNames: Array.from(fileNames), entryPointCode}, sourceMapFiles, config, projectRoot); } catch(err) { console.error(err); process.exit(1); } finally { process.exit(exitCode) } }); return { serializer: { /** * Select user files ONLY (no vendor) to be obfuscated. That code should be tagged with * {@JSCRAMBLER_BEG_ANNOTATION} and {@JSCRAMBLER_END_ANNOTATION}. * Also gather metro source-maps in case of instrumentation process. * @param {{output: Array<*>, path: string, getSource: function():Buffer}} _module * @returns {boolean} */ processModuleFilter(_module) { calledByMetro = true; const modulePath = _module.path; const shouldSkipModule = !validateModule(modulePath, config, projectRoot); if (shouldSkipModule) { return true; } const normalizePath = buildNormalizePath(modulePath, projectRoot); fileNames.add(normalizePath); _module.output.forEach(({data}) => { if (instrument && Array.isArray(data.map)) { sourceMapFiles.push({ filename: `${normalizePath}.map`, content: buildModuleSourceMap( data, normalizePath, _module.getSource().toString() ) }); } if (modulePath.includes(INIT_CORE_MODULE)){ entryPointCode = data.code; } data.code = wrapCodeWithTags(data.code); }); return true; } } }; };