jscrambler-webpack-plugin
Version:
A webpack plugin to protect your generated bundle using Jscrambler Code Integrity
398 lines (392 loc) • 16.1 kB
JavaScript
;
const {
basename,
normalize
} = require('path');
const {
readFileSync
} = require('fs');
const assert = require('assert');
const astring = require('astring');
const esrecurse = require('esrecurse');
const acorn = require('acorn');
const client = require('jscrambler').default;
const {
SourceMapSource
} = require('webpack-sources');
const JSCRAMBLER_IGNORE = '.jscramblerignore';
const sourceMaps = !!client.config.sourceMaps;
const instrument = !!client.config.instrument;
const PLUGIN_NAME = 'JscramblerPlugin';
const PROTECTABLE_ASSET_REGEX = /\.(js|mjs|cjs|html|htm)$/;
const SOURCE_MAP_ASSET_REGEX = /\.(js|mjs|cjs)\.map$/;
const OBFUSCATION_LEVELS = {
MODULE: 'module',
BUNDLE: 'bundle'
};
const OBFUSCATION_HOOKS = {
EMIT: 'emit',
PROCESS_ASSETS: 'processAssets'
};
function chunkMatchesFilter(chunk, filter) {
if (typeof filter === 'string') {
return filter === chunk.name;
}
if (typeof filter === 'function') {
return filter(chunk);
}
if (filter instanceof RegExp) {
return filter.exec(chunk.name) !== null;
}
throw new Error(`Unsupported chunk filtering: value was ${filter} (expected string, RegExp or function)`);
}
class JscramblerPlugin {
constructor(_options) {
let options = _options;
if (typeof options !== 'object' || Array.isArray(options)) options = {};
this.options = Object.assign({
excludeList: client.config.excludeList || [],
obfuscationHook: OBFUSCATION_HOOKS.PROCESS_ASSETS,
obfuscationLevel: OBFUSCATION_LEVELS.BUNDLE
}, options, {
clientId: 2
});
if (![OBFUSCATION_LEVELS.BUNDLE, OBFUSCATION_LEVELS.MODULE].includes(this.options.obfuscationLevel)) {
throw new Error(`Unknown obfuscation level ${this.options.obfuscationLevel}. Options: ${OBFUSCATION_LEVELS.BUNDLE} or ${OBFUSCATION_LEVELS.MODULE}`);
}
if (![OBFUSCATION_HOOKS.EMIT, OBFUSCATION_HOOKS.PROCESS_ASSETS].includes(this.options.obfuscationHook)) {
throw new Error(`Unknown obfuscation hook ${this.options.obfuscationHook}. Options: ${OBFUSCATION_HOOKS.EMIT} or ${OBFUSCATION_HOOKS.PROCESS_ASSETS}`);
}
this.instrument = instrument;
if (typeof options.instrument === 'boolean') {
this.instrument = options.instrument;
}
this.jscramblerOp = this.instrument ? client.instrumentAndDownload : client.protectAndDownload;
this.processResult = this.processResult.bind(this);
this.processSourceMaps = this.processSourceMaps.bind(this);
if (client.config.filesSrc || client.config.filesDest || options.filesSrc || options.filesDest) {
console.warn(`(${PLUGIN_NAME}) Options *filesSrc* and *filesDest* were ignored. Webpack entry and output fields will be used instead!`);
}
if (typeof this.options.ignoreFile === 'string') {
if (basename(this.options.ignoreFile) !== JSCRAMBLER_IGNORE) {
throw new Error(`(${PLUGIN_NAME}) *ignoreFile* option must point to .jscramblerignore file`);
}
this.ignoreFileSource = {
content: readFileSync(this.options.ignoreFile, {
encoding: 'utf-8'
}),
filename: JSCRAMBLER_IGNORE
};
}
if (this.options.obfuscationLevel === OBFUSCATION_LEVELS.MODULE) {
if (sourceMaps) {
throw new Error(`(${PLUGIN_NAME}) obfuscationLevel=${this.options.obfuscationLevel} is not compatible with source maps generation.`);
}
if (!Array.isArray(this.options.chunks)) {
throw new Error(`(${PLUGIN_NAME}) when obfuscationLevel=${this.options.obfuscationLevel} you must specify the chunks list`);
}
console.log(`(${PLUGIN_NAME}) Obfuscation Level set to module`);
}
}
forEachModule(chunkName, contentOrTree, it) {
const tree = typeof contentOrTree === 'string' ? acorn.parse(contentOrTree, {
ecmaVersion: "latest"
}) : contentOrTree;
const options = this.options;
esrecurse.visit(tree, {
ObjectExpression(node) {
for (const module of node.properties) {
assert(module.key.type === 'Literal');
assert(module.value.type === 'FunctionExpression');
const moduleId = module.key.value.replace('../', '');
const moduleFilename = normalize(`${chunkName}/${moduleId}.js`);
const keep = it({
moduleFilename,
functionNode: module.value,
moduleId,
tree
});
if (keep === false) {
break;
}
}
return false;
}
});
}
getWebpackMajorVersion(compiler) {
if (!compiler.hooks) {
return 3;
}
return compiler.webpack && typeof compiler.webpack.version === 'string' ? parseInt(compiler.webpack.version.split('.')[0], 10) : 4;
}
updateJscramblerObfuscationAsset(compilation, assets, filename, newContent) {
if (compilation && typeof compilation.updateAsset === 'function' && compilation.compiler) {
compilation.updateAsset(filename, new compilation.compiler.webpack.sources.RawSource(newContent));
} else if (newContent instanceof SourceMapSource) {
assets[filename] = newContent;
} else {
assets[filename] = {
source() {
return newContent;
},
size() {
return newContent.length;
}
};
}
}
assertEmitHook() {
if (this.options.obfuscationHook === OBFUSCATION_HOOKS.PROCESS_ASSETS) {
throw new Error(`obfuscation hook ${this.options.obfuscationHook} is only compatible with webpack version 5 or higher. Change to: ${OBFUSCATION_HOOKS.EMIT} (default)`);
}
}
/**
* The hooks setup depend on the webpack version
* - <= v3 use compiler.plugin
* - v4 or if sourcemaps it set use compiler.hooks.emit
* - >= v5 use processAssets hook
* @param compiler
* @returns {function(*): *}
*/
attachHooks(compiler) {
const webpackMajorVersion = this.getWebpackMajorVersion(compiler);
// noinspection FallThroughInSwitchStatementJS
switch (webpackMajorVersion) {
case 3:
this.assertEmitHook();
return arg => compiler.plugin(this.options.obfuscationHook, (compilation, callback) => {
compilation.updateJscramblerObfuscationAsset = this.updateJscramblerObfuscationAsset.bind(this, undefined);
arg(compilation, callback);
});
case 4:
this.assertEmitHook();
case 5:
default:
if (this.options.obfuscationHook === OBFUSCATION_HOOKS.EMIT || Number.isNaN(webpackMajorVersion) || webpackMajorVersion === 4) {
return arg => compiler.hooks.emit.tapAsync(PLUGIN_NAME, (compilation, callback) => {
compilation.updateJscramblerObfuscationAsset = this.updateJscramblerObfuscationAsset.bind(this, undefined);
arg(compilation, callback);
});
}
return arg => compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
let obfuscated = false;
const obfuscateAssets = (assets, callback) => {
if (!obfuscated) {
obfuscated = true;
const chunks = Array.from(compilation.chunks.values());
arg({
chunks,
assets: compilation.assets,
compiler: compilation.compiler,
updateJscramblerObfuscationAsset: this.updateJscramblerObfuscationAsset.bind(this, compilation)
}, callback);
} else {
callback();
}
};
// obfuscation logic is in the additionalAssets, so the processAssets callback can be empty
const voidProcessAssetsFn = (assets, callback) => {
callback();
};
/**
* additionalAssets is the better place to run the obfuscation
* because the work is “generate/replace a bunch of assets asynchronously”
* rather than “transform the current assets object inline”.
*/
compilation.hooks.processAssets.tapAsync({
name: PLUGIN_NAME,
stage: compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
additionalAssets: obfuscateAssets
}, voidProcessAssetsFn);
if (sourceMaps) {
let handleSourceMaps = false;
// original source-maps are generated by webpack and the assets can not be changed until the source-map is generated, thus we can only obfuscate after the PROCESS_ASSETS_STAGE_DEV_TOOLING step
const fetchJscramblerSourceMaps = (assets, callback) => {
if (!handleSourceMaps) {
handleSourceMaps = true;
compilation.updateJscramblerObfuscationAsset = this.updateJscramblerObfuscationAsset.bind(this, undefined);
this.downloadAndProcessSourceMaps(this.protectionId, compilation, callback);
} else {
callback();
}
};
compilation.hooks.processAssets.tapAsync({
name: PLUGIN_NAME,
stage: compilation.PROCESS_ASSETS_STAGE_DEV_TOOLING,
additionalAssets: fetchJscramblerSourceMaps
}, voidProcessAssetsFn);
}
});
}
}
/**
* @param {string} filename source map or matching source code file name
* @param {object} compilation
* @returns {{sourceMapContent?: string, sourceMapFilename: string}}
*/
getSourceMapInfo(filename, compilation) {
let sourceMapFilename = `${filename}.map`;
if (SOURCE_MAP_ASSET_REGEX.test(filename)) {
sourceMapFilename = filename;
}
const sourceMap = compilation.assets[sourceMapFilename];
return {
sourceMapContent: sourceMap && sourceMap.source(),
sourceMapFilename
};
}
apply(compiler) {
const enable = this.options.enable !== undefined ? this.options.enable : true;
if (!enable) {
console.warn(`${PLUGIN_NAME} is disabled!`);
return;
}
this.attachHooks(compiler)((compilation, callback) => {
const sources = [];
compilation.chunks.forEach(chunk => {
if (Array.isArray(this.options.chunks) && !this.options.chunks.some(filter => chunkMatchesFilter(chunk, filter))) {
return;
}
chunk.files.forEach(filename => {
if (PROTECTABLE_ASSET_REGEX.test(filename)) {
const content = compilation.assets[filename].source();
if (this.options.obfuscationLevel === OBFUSCATION_LEVELS.BUNDLE) {
sources.push({
content,
filename
});
} else if (this.options.obfuscationLevel === OBFUSCATION_LEVELS.MODULE) {
this.forEachModule(filename, content, ({
moduleFilename,
functionNode,
moduleId
}) => {
sources.push({
content: astring.generate(functionNode.body),
filename: moduleFilename
});
// add function arguments to exclude list
if (Array.isArray(functionNode.params)) {
functionNode.params.filter(n => n.type === 'Identifier').forEach(n => {
if (!this.options.excludeList.includes(n.name)) {
this.options.excludeList.push(n.name);
}
});
}
});
}
}
if (this.instrument || sourceMaps) {
const {
sourceMapContent,
sourceMapFilename
} = this.getSourceMapInfo(filename, compilation);
if (sourceMapContent && sources.every(filename => filename !== sourceMapFilename)) {
sources.push({
content: sourceMapContent,
filename: sourceMapFilename
});
}
}
});
});
if (sources.length > 0) {
console.log(`${PLUGIN_NAME}: sent ${sources.filter(({
filename
}) => !filename.endsWith('.map')).length} file(s) for protection`);
if (this.ignoreFileSource) {
sources.push(this.ignoreFileSource);
}
Promise.resolve(this.jscramblerOp.call(client, Object.assign(this.options, {
sources,
stream: false
}), res => {
this.protectionResult = res.map(p => {
// normalize name. F.e. if the original names starts with "./", the protected version must also be set with "./" prefix
p.filename = (sources.find(({
filename: oFilename
}) => new RegExp(`^(./)*${p.filename}$`).test(oFilename)) || p).filename;
return p;
});
})).then(protectionId => this.processResult(protectionId, compilation, (...args) => {
console.log(`${PLUGIN_NAME}: protection ${protectionId} ended`);
callback(...args);
})).catch(err => {
callback(err);
});
} else {
callback();
}
});
}
processSourceMaps(results, compilation, callback) {
for (const result of results) {
const sourceFilename = result.filename.slice(0, -4).replace('jscramblerSourceMaps/', '');
const sm = JSON.parse(result.content);
if (compilation.assets[sourceFilename]) {
compilation.updateJscramblerObfuscationAsset(compilation.assets, `${sourceFilename}.map`, result.content);
if (this.options.obfuscationHook === OBFUSCATION_HOOKS.EMIT) {
const content = compilation.assets[sourceFilename].source();
compilation.updateJscramblerObfuscationAsset(compilation.assets, sourceFilename, new SourceMapSource(content, sourceFilename, sm));
}
}
}
callback();
}
processResult(protectionId, compilation, callback) {
this.protectionId = protectionId;
const results = this.protectionResult;
const protectTreesOrCodeMap = new Map();
for (const result of results) {
if (result.filename === JSCRAMBLER_IGNORE) {
continue;
}
if (this.options.obfuscationLevel === OBFUSCATION_LEVELS.BUNDLE) {
protectTreesOrCodeMap.set(result.filename, result.content);
} else if (this.options.obfuscationLevel === OBFUSCATION_LEVELS.MODULE) {
const [chunkFileName] = result.filename.split('/');
let found = false;
this.forEachModule(chunkFileName, protectTreesOrCodeMap.get(chunkFileName) || compilation.assets[chunkFileName].source(), ({
moduleFilename,
functionNode,
tree
}) => {
if (result.filename === moduleFilename) {
protectTreesOrCodeMap.set(chunkFileName, tree);
functionNode.body.body = [{
type: 'Identifier',
name: result.content
}];
found = true;
return false;
}
});
if (!found) {
throw new Error(`[Jscrambler] An Inconsistency found with obfuscationLevel=${this.options.obfuscationLevel}. Please contact us.`);
}
}
}
for (const [chunkFileName, treeOrCode] of protectTreesOrCodeMap.entries()) {
const bundleCode = typeof treeOrCode === 'string' ? treeOrCode : astring.generate(treeOrCode, {
indent: ""
});
compilation.updateJscramblerObfuscationAsset(compilation.assets, chunkFileName, bundleCode);
}
// turn off source-maps download if jscramblerOp is instrumentAndDowload
// source-maps on v5 and onwards must be process in stage - PROCESS_ASSETS_STAGE_DEV_TOOLING
if (!this.instrument && sourceMaps && this.options.obfuscationHook === OBFUSCATION_HOOKS.EMIT) {
this.downloadAndProcessSourceMaps(protectionId, compilation, callback);
return;
}
callback();
}
downloadAndProcessSourceMaps(protectionId, compilation, callback) {
console.log(`${PLUGIN_NAME}: downloading source-maps`);
client.downloadSourceMaps(Object.assign({}, client.config, this.options, {
stream: false,
protectionId
}), res => this.processSourceMaps(res, compilation, callback));
}
}
module.exports = JscramblerPlugin;