[JavaScript] フロントエンドコードの難読化とその限界

はじめに

WebアプリやChrome拡張のように、フロントエンドのコードはユーザーの手元にそのまま配布されます。
つまり、どんなに工夫しても「完全に隠す」ことはできないのが前提です。

そこで使われるのが「難読化(Obfuscation)」です。
これはセキュリティ技術というよりは、コードを一目で理解されにくくするための小細工です。

今回の記事では、代表的な方法を3つ整理します。

  • Minify(縮小化) – 空白やコメント削除、変数名の短縮
  • Obfuscate(難読化) – 制御フローの変形や文字列の暗号化
  • Bundle化 – ファイルをまとめて構造を分かりにくくする

それぞれの効果と限界を、サンプルコードを交えて解説していきます。

Minify(縮小化)

■ 目的
 ・サイズ削減
 ・読みづらさ向上
 ・ネットワーク負荷軽減
■ 代表ツール
 ・Terser(推奨)
 ・UglifyJS(ES5中心)

実務では Terser 一択でよい
UglifyJS は ES2015+ の最適化対応が弱い歴史があり
Chrome拡張や現代的フロントエンドでは相性が悪いことが多い




1. 最小セットアップ

# プロジェクト直下で
npm i -D terser

1ファイルだけ最小圧縮

npx terser src/content.js -o dist/content.min.js -c -m
  • -c = compress
    (不要コードの除去、式の単純化)
  • -m = mangle
    (ローカル識別子の短縮)

ソースマップ付き

npx terser src/content.js -o dist/content.min.js -c -m --source-map "url=content.min.js.map"

Chrome拡張でもデバッグ時にソースマップが見られるため、開発ビルドでは有効にしておくと便利。



2. よく使うオプション

package.json の scripts 例:

{
  "scripts": {
    "build:dev": "terser src/*.js -o dist/bundle.dev.js -c -m --source-map",
    "build:prod": "terser src/*.js -o dist/bundle.min.js -c drop_console=true,drop_debugger=true -m --ecma 2020 --comments=/^!|@preserve|@license/"
  }
}
  • drop_console=true: 本番で console.* を削除
  • drop_debugger=truedebugger; を削除
  • --ecma 2020: 出力コードのターゲット(2020 などを指定)
  • --comments=/^!|@preserve|@license/: ライセンスコメントを残す(配布物の法的配慮)

より細かく管理したい場合は 設定ファイル

// terser.config.cjs
module.exports = {
  ecma: 2020,
  compress: {
    passes: 2,               // 最適化の繰り返し回数
    booleans_as_integers: true,
    drop_console: process.env.NODE_ENV === 'production',
    drop_debugger: process.env.NODE_ENV === 'production',
    // 特定の副作用なし関数を「消してよい」マークにできる(例)
    pure_funcs: ["assert", "invariant"]
  },
  mangle: {
    toplevel: false,         // ライブラリ化するなら false が安全
    keep_classnames: false,  // スタックトレース重視なら true
    keep_fnames: false       // 同上
  },
  format: {
    comments: /(^!|@preserve|@license)/,
  }
};

実行:

NODE_ENV=production npx terser src/*.js -o dist/app.min.js --config-file terser.config.cjs --source-map

3. Before / After(最小例)

Before:(拡張の content script 想定)

// content.js
function isQuestion(line) {
  return /[\??]\s*$/.test(line);
}

function redactParagraphs() {
  const paras = document.querySelectorAll(".markdown p");
  paras.forEach(p => {
    if (isQuestion(p.innerText)) {
      p.innerText = "[質問は省略されました]";
    }
  });
}

const obs = new MutationObserver(() => redactParagraphs());
obs.observe(document.body, { childList: true, subtree: true });
redactParagraphs();

After:(抜粋)

function n(e){return/[\??]\s*$/.test(e)}function t(){document
 .querySelectorAll(".markdown p").forEach(e=>{n(e.innerText)&&(e.innerText="[質問は省略されました]")})}
const o=new MutationObserver(()=>t());o.observe(document.body,{childList:!0,subtree:!0}),t();
  • 変数名・空白が削られ可読性低下
  • さらに -c passes=2 などで式が畳み込まれる

4. 事故を防ぐ注意点

  • 関数名やクラス名に依存しているコード 例:スタックトレースや DI フレームワークが constructor.name を参照 → --mangle keep_fnames=true keep_classnames=true で温存

  • プロパティ名の難読化は原則オフ 動的アクセス(obj[propName])や外部APIとの約束フィールドは壊れやすい → Terserの mangle.properties は上級者向け。使うなら reserved で除外リスト必須

  • 副作用のある関数が消されるリスク pure_funcs に入れた関数は「戻り値未使用なら削除」される → ログ関数や計測関数など、本当に消して良いものだけ指定

  • 拡張のサービスワーカー(MV3) 長時間ログが欲しい場合は drop_console を本番でも切らない判断もあり → build:prodbuild:store を分ける運用が安全


5. esbuild / rollup / webpack で「ついでに minify」

バンドラを使うなら フラグ一つで minify できる。速度重視は esbuild

# esbuild(速い)
npx esbuild src/content.js --bundle --minify --target=es2020 --outfile=dist/content.min.js

Terser は圧縮の深さ微調整で依然優位。 スピードは esbuild、チューニングは Terser、という住み分けが実務では多い。


6. Chrome拡張(MV3)での導入ひな形

// manifest.json(抜粋)
{
  "manifest_version": 3,
  "name": "ChatGPT Question Redactor",
  "version": "1.0.0",
  "content_scripts": [
    {
      "matches": ["https://chat.openai.com/*","https://chatgpt.com/*"],
      "js": ["content.min.js"]
    }
  ]
}
  • 開発中は content.dev.js、公開時は content.min.js を差し替え
  • ストア審査向けに ライセンス表記を残す 設定が安全

7. 小ワザ

  • 環境切り替えprocess.env.NODE_ENV による drop_console のON/OFF
  • ライセンス保存:ファイル先頭に /*! @license MIT */ などを書くと --comments=/^!/ で保持
  • サイズ限界:拡張はパッケージサイズ制約がある(縮小の恩恵が効きやすい)

Obfuscation(難読化) — 実務向けガイド

JavaScript の難読化は「解析コストを上げる」手段であって、秘匿やセキュリティの代替にはならない。

重要な秘密(APIキーや認証トークンなど)は絶対にクライアント側に置かないこと。

ツールとして代表的なのは javascript-obfuscator(npm / GitHub)で、オンライン UI もある。(GitHub)


1. 導入(最短)

# プロジェクトに導入
npm install --save-dev javascript-obfuscator
# 単体ファイルを CLI で難読化
npx javascript-obfuscator src/content.js --output dist/content.obf.js

オンライン版(GUI)を使うと細かいオプションを試せる。(obfuscator.io)


2. CLI / プログラム API の例

CLI(主なオプション)

npx javascript-obfuscator src/content.js \
  --output dist/content.obf.js \
  --compact true \
  --control-flow-flattening false \
  --string-array true \
  --string-array-encoding base64 \
  --self-defending false \
  --source-map true

Node API(プログラム呼び出し)

const JavaScriptObfuscator = require('javascript-obfuscator');
const fs = require('fs');

const input = fs.readFileSync('src/content.js', 'utf8');
const obfuscated = JavaScriptObfuscator.obfuscate(input, {
  compact: true,
  controlFlowFlattening: false,
  stringArray: true,
  stringArrayEncoding: ['base64'],
  rotateStringArray: true
});
fs.writeFileSync('dist/content.obf.js', obfuscated.getObfuscatedCode());
if (obfuscated.getSourceMap()) {
  fs.writeFileSync('dist/content.obf.js.map', obfuscated.getSourceMap());
}

公式リポジトリ/npm に詳細オプションあり。(GitHub)


3. 主要オプションと効果(実務で覚えておくもの)

  • compact:余分な空白を削る(サイズ改善)
  • controlFlowFlattening:制御フロー変形(解析困難度↑) — 性能悪化・サイズ増の要因。高設定は避けるのが無難。(ByteHide Documentation)
  • stringArray / stringArrayEncoding:文字列群を配列化して暗号化(重要な文字列を読みづらく)
  • rotateStringArray:文字列配列を並び替えて更に難読化
  • deadCodeInjection:到達しないダミーコードを追加して解析をかく乱(サイズ増)
  • selfDefending / debugProtection:実行時に解析ツールを邪魔する仕掛け(ブラウザ上の副作用や互換性リスク)

controlFlowFlatteningdeadCodeInjection は解析コストを大きく上げる一方でパフォーマンスやビルド時間にも大きな影響が出る。大きなファイルだと処理が極端に遅くなる報告もあるので注意。(GitHub)


4. Before / After(超短縮例)

Before

function redact(text) {
  if (/[\??]\s*$/.test(text)) return '[質問は省略されました]';
  return text;
}

After(obfuscated 抜粋)

var _0x3a4b=['[質問は省略されました]','replace'];(function(_0x5bfe,_0x3a4b2){...})();
function _0x1a(a){return/[\??]\s*$/.test(a)?_0x3a4b[0]:a}

(実際はさらに control-flow 等で意味不明な分岐追加・文字列配列化される)


5. 実務的な「推奨セット(Chrome拡張向け)」

拡張はサイズと実行コストに敏感なので、まずは軽めの設定で安全に始める:

{
  "compact": true,
  "controlFlowFlattening": false,
  "stringArray": true,
  "stringArrayEncoding": ["base64"],
  "rotateStringArray": true,
  "deadCodeInjection": false,
  "selfDefending": false
}
  • これで読みにくくはなるが、ランタイム負荷は抑えられる。
  • controlFlowFlattening / deadCodeInjection を本気で使うならベンチとユーザーテスト必須。性能劣化やバグ誘発の事例がある。(GitHub)

6. 注意点・落とし穴(重要)

  • パフォーマンスとサイズ
    強力なオプションはバンドルサイズと実行コスト(CPU)を増やす。ユーザー体感や起動速度を必ず測る。(ByteHide Documentation)
  • 互換性
    Electron / 特殊ランタイムや古いブラウザでは動作しないケースが報告される(特に deadCode / stringArray の副作用)。(GitHub)
  • デバッグ性の低下
    ソースマップを使えば開発時にデバッグ可能だが、公開版にソースマップを置くと元のコードが露出するリスクがある。公開時はソースマップを配布しない運用が基本。
  • 検出・解析は可能
    deobfuscator ツールや動的実行(実行時に逆解析する)で復元される可能性がある。難読化は「時間稼ぎ」に過ぎない。
  • ライセンス・法的注意
    ライブラリ固有のコメント(ライセンス)を残すオプションは利用する方が安全。(GitHub)

7. 代替・商用サービスとトレードオフ

  • OSS の javascript-obfuscator は手軽で無料。(GitHub)
  • 商用サービス(Jscrambler や各種プロテクトサービス)はより高度な保護(アンチ-tamper、ドメインロック等)を提供するがコストがかかる。用途に応じて検討。(Jscrambler)

8. 実運用ワークフロー(例)

  1. build:dev → ソースマップ付きで content.dev.js(デバッグ可)
  2. CI ビルドで javascript-obfuscator 実行して content.obf.js を生成
  3. 自動テスト(E2E / 起動テスト)を必須で回す
  4. パフォーマンス測定(初回ロード、操作遅延)を比較
  5. 問題なければパッケージ化して公開(ソースマップは除外)

9. 実践小ネタ

  • 段階的導入:まず compact + stringArray、次に rotateStringArray、最終的に controlFlowFlattening を試す。段階ごとに動作確認する。
  • テストを自動化:CI に簡単な UI スモークテストを入れて、obfuscate 後の破壊的変更を検知する。
  • 大きなファイルは避ける:巨大な JS バンドルを丸ごと obfuscate するとビルド時間が跳ね上がる。モジュール単位で分割して処理する。

参考(主要リンク)

  • javascript-obfuscator(公式 GitHub / npm) — 実装・オプション参照。(GitHub)
  • Obfuscator オンライン UI(obfuscator.io)。(obfuscator.io)
  • 性能・制御フローに関する解説(企業ドキュメント) — 高コスト変換について。(ByteHide Documentation)
  • 大ファイルでの時間問題・既知 Issue(GitHub Issues)。(GitHub)

Bundle化 — 複数ファイルを1本にまとめて配布

狙い

  • 依存をひとまとめにして配布・読み込みを簡略化
  • import/export 構造を消して解析コストを上げる(※秘匿にはならない)
  • Tree-shaking でサイズ削減、Minify 併用でさらに効く

代表ツールesbuild(速い) / rollup(細かい制御) / webpack(統合力)


1. esbuild(最速・CLI一発)

# 導入
npm i -D esbuild

# 単一エントリをバンドル+ミニファイ
npx esbuild src/content.ts --bundle --minify --sourcemap \
  --platform=browser --target=es2020 \
  --outfile=dist/content.min.js

ポイント

  • --bundle:依存を内包

  • --platform=browser / --target=es2020:出力互換を指定

  • --sourcemap:開発中のみ付与、本番配布は外す

  • ライセンスを残したい場合:

    npx esbuild src/index.js --bundle --minify --outfile=dist/app.js \
      --banner:js="/*! @license MIT (c) lain */"
    

コード分割(動的 import) MV3 の サービスワーカーでは dynamic import OK。ただし content scripts は基本「単一ファイル指定」なので分割せず 1 本にするのが楽。

npx esbuild src/bg/index.ts --bundle --minify --sourcemap \
  --format=esm --outfile=dist/bg.js

2. rollup(細かい制御・ライブラリ向け)

設定ファイル例(最小)

npm i -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs
// rollup.config.mjs
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'src/content.js',
  output: {
    file: 'dist/content.bundle.js',
    format: 'iife', // content-scriptは自己実行形式が扱いやすい
    sourcemap: true
  },
  plugins: [resolve(), commonjs()]
};

実行:

npx rollup -c

ポイント

  • format: 'iife' は content script と相性が良い
  • ライブラリ配布なら format: 'esm' / cjs などを併産
  • 追加で @rollup/plugin-terser を入れれば minify も同時適用

3. webpack(拡張性が高く、資産が多い)

npm i -D webpack webpack-cli
// webpack.config.js
module.exports = {
  mode: 'production',
  entry: './src/content.js',
  output: { filename: 'content.bundle.js', path: __dirname + '/dist' },
  devtool: 'source-map', // 開発のみ推奨。公開時は外す
  // loader が必要な場合は module.rules で拡張
};

実行:

npx webpack

ポイント

  • Loader/Plugin が豊富(Babel, TypeScript, CSS など一式)
  • 単純な JS だけなら esbuild の方が設定が軽い

4. Chrome拡張(MV3)での実務ポイント

manifest.json(抜粋)

{
  "manifest_version": 3,
  "name": "ChatGPT Question Redactor",
  "version": "1.0.0",
  "background": { "service_worker": "bg.js", "type": "module" },
  "content_scripts": [
    { "matches": ["https://chat.openai.com/*","https://chatgpt.com/*"], "js": ["content.min.js"] }
  ]
}
  • 背景スクリプト(service_worker)ESM可:esbuild/rollup で format: esm を出力

  • コンテントスクリプト単一JSファイルで指定するのが無難(分割しない)

  • 外部依存の除外(externalize):Web拡張向けの webextension-polyfill 等を CDN から読まない構成にする場合、バンドルに含めるか external 指定で分ける

    • esbuild:--external:webextension-polyfill
    • rollup:external: ['webextension-polyfill']

5. Tree-shaking と副作用

  • 副作用のないモジュールは未使用コードが落ちる(Tree-shaking)
  • パッケージ側が package.json"sideEffects": false を持っていると効果的
  • 自前コードでも「未使用の export は消える」前提で設計するとサイズが落ちやすい

6. バンドル後のミニファイ/難読化の併用

  • スピード重視:esbuild で --minify(十分速く小さい)

  • 圧縮を攻める:Terser を後段に挟む(細かいチューニング)

  • 読みづらさを上げる:バンドル後に javascript-obfuscator をかける

    • 速度・互換性を見ながら軽め設定で運用(前章の推奨セット)

7. ソースマップと配布

  • 開発:sourcemap を出す(デバッグ容易)
  • 公開:ソースマップは配布しない運用にする
  • どうしても配布したい場合は 別ホストで配布し、拡張のパッケージからは外す

8. 失敗しがちな点

  • Content script の CSS/Asset を一緒に入れ忘れ

    • 画像やフォントが必要なら web_accessible_resources に列挙
  • Dynamic import を content script で多用

    • 読み込みが増えて遅くなる。1ファイル出力が安定
  • 巨大バンドルに難読化をかけてビルドが激遅

    • モジュールを分け、必要部分だけ難読化

9. すぐ使えるテンプレ(esbuild + 2本出力)

// package.json(scripts)
{
  "scripts": {
    "build:cs": "esbuild src/content.ts --bundle --minify --target=es2020 --outfile=dist/content.min.js",
    "build:bg": "esbuild src/bg/index.ts --bundle --format=esm --minify --target=es2022 --outfile=dist/bg.js",
    "build": "npm run build:cs && npm run build:bg"
  }
}

難読化の限界と注意点

難読化で期待できる効果

  • 解析コストを上げる(時間稼ぎ)
  • そのままコピペして動かせる確率を下げる ただし「秘匿」にはならない前提で運用すること。

技術的な限界(必ず押さえること)

  • 可逆性がゼロではない:静的解析ツールや deobfuscator、手動リバースで復元される。
  • ランタイムで観察すれば復元できる:ブラウザで実行させて文字列や挙動を観察すれば、ロジックは追える。
  • 重い設定はユーザー体験を壊すcontrolFlowFlattening や大量の deadCodeInjection は実行コストやビルド時間を大幅に増やす。
  • 互換性リスク:特殊ランタイム(Electron、古いブラウザ)や一部ライブラリで動かなくなる可能性がある。
  • ソースマップを公開すると無意味:公開版にソースマップを置くと元のコードが露出する。

セキュリティ上の注意(絶対守ること)

  • APIキー / 機密はクライアントに置かない。短期トークンでも同様に危険。必ずサーバ側で秘匿処理/プロキシ化する。
  • 認証や課金の判定ロジックをクライアントに置かない(改ざんされやすい)。
  • ライセンス表記は残す(法的配慮)。難読化設定でライセンスコメントを保持する方法を使う。

ユーザー体験とパフォーマンスの観点

  • 難読化でページロードや拡張初回動作が遅くならないか計測する。
  • モバイルや低スペック端末での影響を必ず検証する。
  • 重い難読化設定は「オプトイン」(ユーザーの同意がある場面)以外避ける。

実務的な運用・ワークフロー(推奨)

  1. 開発ビルド:ソースマップありでデバッグ可能にする。
  2. CIで自動化:obfuscate は CI のビルドステップで実行。
  3. 自動テスト:obfuscate 後にスモークテスト(起動/主要機能)を必須化。
  4. パフォーマンス測定:起動時間や操作遅延を比較して閾値を設定。
  5. 段階的導入:まず compact + stringArray、問題なければ rotateStringArray、最後に(必要なら)controlFlowFlattening を試す。
  6. リリース運用:ソースマップは公開パッケージに含めない/別ホストで管理する。

検出・解析に対する小さな防御(完全な防御ではない)

  • 文字列暗号化(stringArray):即時の探索を少し遅らせる。
  • 実行時整合チェック:簡単なハッシュで改変検出 → 異常時は機能制限する。ただし回避される可能性は高い。
  • ドメイン限定(商用サービス):商用ツールはドメインロックやアンチ-tamper を提供するが、完全防御は存在しない。コストと効果を比較検討する。

法務・倫理面

  • 難読化で「利用規約やライセンス表記」を隠してはいけない。オープンソース組込時は元ライセンスを明示する。
  • 他人のコードを難読化して配布する場合は著作権を侵害しないことを確認する。

代替(より安全な設計)

  • 秘密実装はサーバサイドへ移す:アルゴリズムや鍵、課金判定は API で処理。
  • サーバ側で差分データだけ渡す:クライアントは単に表示する役割に限定。
  • 短期トークン+最小権限:クライアントが持つトークンは短期間で失効する設計を採る。

まとめ

  • 小規模な Chrome 拡張やユーティリティなら、Minify(Terser / esbuild) だけで十分な場合が多い。
  • 学習目的や解析コストを上げたい場面では javascript-obfuscator を試す価値があるが、強いオプションはサイズ増・CPU負荷・互換性リスクを招く。
  • 本当に秘匿したいロジック/秘密はクライアントに置かない。サーバサイドへ移行して API で処理する運用が正解。

付録:すぐ使える npm スクリプト例

{
  "scripts": {
    "build:cs": "esbuild src/content.ts --bundle --minify --target=es2020 --outfile=dist/content.min.js",
    "build:bg": "esbuild src/bg/index.ts --bundle --format=esm --minify --target=es2022 --outfile=dist/bg.js",
    "obfuscate": "javascript-obfuscator dist/content.min.js --output dist/content.obf.js --compact true --string-array true --string-array-encoding base64",
    "build": "npm run build:cs && npm run build:bg && npm run obfuscate"
  }
}
  • 開発は build:esbuild のみ。
  • 公開時に npm run build:prod を回して dist/content.obf.js を生成する想定。
  • drop_console や obfuscator オプションはプロジェクトに合わせて調整。

CI(GitHub Actions)での自動ビルド+E2E スモーク(最小テンプレ)

.github/workflows/build.yml

name: build-and-test
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 18
      - name: Install
        run: npm ci
      - name: Build (prod)
        run: npm run build:prod
      - name: Run smoke tests (Playwright / Puppeteer minimal)
        run: |
          npm run test:smoke || (echo "Smoke tests failed" && exit 1)
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-dist
          path: dist/

test:smoke は起動確認だけの軽い E2E(例:拡張をローカルで読み込んだときに主要機能が動くか)を想定。obfuscate 後に必須で回すことが重要。

公開前チェックリスト(コピーして README に貼れる)

  • 機密情報(APIキー等)がソースに埋め込まれていない
  • build:prod(minify→terser→obfuscate)を CI で実行している
  • obfuscate 後に起動確認(スモークテスト)を通している
  • パフォーマンス(初回ロード、主要アクション)が許容内である
  • ライセンスコメントや著作表記を残す設定にしている(必要な場合)
  • ソースマップは公開パッケージに含めていない/別ホストで管理している