問題点
フロントエンドのビルド成果物(JavaScript バンドル、CSS、HTML など)は、リポジトリのソースコードとは別にデプロイされます。
ビルド時に環境変数や設定がインライン化されるため、意図しない API キーやシークレットがクライアント側に流出するケースが増えています。
ソースコードだけをスキャンしても、次のような漏洩は検出できません。
-
NEXT_PUBLIC_、REACT_APP_などのフレームワークが自動的に公開するプレフィックス付き環境変数 - ビルドツールが生成した設定ファイル(例:
config.js、env.js) - ライブラリが内部で埋め込むデフォルトキー(例: デモ用公開鍵)
これらは「公開しても問題ない」ものと「実際に漏洩すべきシークレット」を区別しなければ、誤検知や見逃しが発生します。
メカニズム
-
成果物の取得
- ビルド後のディレクトリ(例:
dist/、build/)を対象に、すべてのテキストベースファイルを走査します。 - バイナリは除外し、文字コードは UTF‑8 を前提にします。
- ビルド後のディレクトリ(例:
-
パターンマッチング
- 正規表現ベースのシークレットパターン(API キー、トークン、証明書のシリアル等)を適用します。
- フレームワーク固有の公開プレフィックス(
NEXT_PUBLIC_、REACT_APP_、VITE_など)を除外リストに追加し、意図的に公開された変数はスキップします。
-
コンテキスト分析
- 発見した文字列の前後 20 行を取得し、以下のヒューリスティックで判定します。
-
publicKey,publishableKeyなどのキーワードが同行にあるか -
BEGIN PUBLIC KEY/END PUBLIC KEYの PEM ラベルが存在するか
-
- これにより、公開鍵 と 秘密鍵 を区別します。
- 発見した文字列の前後 20 行を取得し、以下のヒューリスティックで判定します。
-
レポート生成
- 発見されたシークレットは、ファイル名、行番号、マッチした文字列、判定結果(公開/疑わしい)を JSON または Markdown で出力します。
- CI/CD パイプラインに組み込めるよう、非ゼロ終了コードで「疑わしいシークレットが検出された」ことを示します。
実装例
# 依存パッケージ
pip install regex tqdm
# scan_artifacts.py
import pathlib, json, sys, re
from tqdm import tqdm
# 1. ビルド成果物ディレクトリ
ROOT = pathlib.Path(sys.argv[1] or "dist")
# 2. 公開プレフィックス除外リスト
PUBLIC_PREFIXES = [
r"NEXT_PUBLIC_",
r"REACT_APP_",
r"VITE_",
]
# 3. シークレットパターン(例: Stripe の公開可能キー、AWS のシークレットアクセスキー)
PATTERNS = {
"stripe_publishable": re.compile(r"pk_live_[0-9a-zA-Z]{24}"),
"aws_secret": re.compile(r"AKIA[0-9A-Z]{16}"),
"generic_secret": re.compile(r"[A-Za-z0-9]{40,}"),
}
# 4. 公開鍵判定ヘルパー
def is_public_key(snippet: str) -> bool:
return bool(re.search(r"BEGIN PUBLIC KEY|END PUBLIC KEY|publicKey", snippet, re.I))
# 5. スキャン本体
def scan_file(path: pathlib.Path):
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return [] # バイナリはスキップ
findings = []
for name, pat in PATTERNS.items():
for m in pat.finditer(text):
# 除外チェック
if any(re.search(pref, text[:m.start()]) for pref in PUBLIC_PREFIXES):
continue
# 前後コンテキスト取得
start = max(0, m.start() - 200)
end = min(len(text), m.end() + 200)
snippet = text[start:end]
# 公開鍵かどうか判定
public = is_public_key(snippet)
findings.append({
"file": str(path),
"line": text[:m.start()].count("\n") + 1,
"type": name,
"value": m.group(0),
"public_key": public,
"snippet": snippet.strip().replace("\n", " "),
})
return findings
def main():
all_findings = []
for path in tqdm(list(ROOT.rglob("*.*"))):
all_findings.extend(scan_file(path))
# レポート出力
print(json.dumps(all_findings, indent=2, ensure_ascii=False))
# 疑わしいシークレットがあれば非ゼロ終了
sys.exit(1 if any(not f["public_key"] for f in all_findings) else 0)
if __name__ == "__main__":
main()
CI での利用例(GitHub Actions)
name: Secret Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install deps
run: pip install regex tqdm
- name: Build
run: npm ci && npm run build
- name: Run scanner
run: python scan_artifacts.py ./dist
注意点
| 項目 | 説明 |
|---|---|
| 誤検知 | 公開プレフィックスの除外リストが不完全だと、実際に公開すべきキーまでフラグが立つ可能性があります。プロジェクト固有のプレフィックスは必ず追加してください。 |
| 暗号化キー | PEM 形式の公開鍵は BEGIN PUBLIC KEY で判定できますが、暗号化された秘密鍵は同様のラベルが無いことが多く、必ずしも検出できません。別途サーバー側のシークレット管理を併用してください。 |
| パフォーマンス | ビルド成果物が数千ファイルに及ぶ場合、正規表現の走査がボトルネックになることがあります。tqdm のようにプログレス表示しつつ、マルチプロセス (multiprocessing) で分割実行すると改善できます。 |
| CI の失敗基準 | 本スクリプトは「公開鍵ではないシークレットが見つかった」場合に非ゼロで終了します。プロジェクトのリスク許容度に応じて、公開鍵だけでも警告 とする設定に変更可能です。 |
| 言語・フレームワーク依存 | 本例は JavaScript 系ビルドを想定していますが、同様のロジックは WebAssembly、React Native、Electron などのバンドルにも適用できます。対象ファイル拡張子を調整してください。 |
まとめ
- ソースコードだけでなく、ビルド成果物を対象に