3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLMでプライベート領域にあるコード内のクレデンシャルを検知する

Last updated at Posted at 2025-12-18

こんにちは、株式会社インティメート・マージャー 開発本部の @y_kanno です。

1. はじめに

業務で扱うリポジトリの中には、セキュリティポリシー上、ChatGPTなどのクラウドベースのAIサービスにコードを渡せないものが数多く存在します。
しかし、CI/CDパイプラインやpre-commitフック等での自動セキュリティチェック(特にAPIキーやパスワードのハードコード検出)は行いたいものです。

もちろん、trufflehogといった正規表現ベースのツールの利用も考えられますが、テスト用文字列の誤検知等、文脈を考慮しない動作に悩まされることもあります。
例えば password = os.getenv('DB_PASS') は安全ですが、正規表現で password = を探すルールに引っかかるかもしれません。

そこで今回は 「完全オフライン」 で動作するローカルLLM環境を構築し、文脈を理解できるAIにコード内の機密情報を指摘してもらうPythonスクリプトを作成します。

2. アーキテクチャと技術選定

今回は以下の構成で実装します。

  • LLMランタイム: Ollama
  • モデル: qwen2.5-coder または llama3.2
  • 実行言語: Python 3.10+

(環境設定後の)外部通信は一切行わず、
ローカルホスト内 localhost:11434 だけで完結させます。
今回は軽量かつコード理解に向いている qwen2.5-coderを使用します。

事前準備

まず、ローカルLLMサーバーであるOllamaをインストールし、モデルをpullしておきます。

# Ollamaのインストール(Mac/Linuxの場合)
curl -fsSL https://ollama.com/install.sh | sh

# モデルの準備(今回は軽量なqwen2.5-coderを使用)
ollama pull qwen2.5-coder:7b

実装コード (Python)

このスクリプトで

  • 指定したディレクトリ内のファイルを走査
  • LLMにファイルを投げて「ここに認証情報は含まれているか?」の判定

を実行します。

scan_script.py
import os
import json
import requests
import fnmatch

# 設定
TARGET_DIR = "./src"  # チェックしたいディレクトリ
OLLAMA_API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "qwen2.5-coder:7b"

# チェック対象外のファイル/ディレクトリ
IGNORE_PATTERNS = [
    "*.pyc", "__pycache__", ".git", "node_modules", 
    "*.png", "*.jpg", "*.lock"
]

def is_ignored(path):
    for pattern in IGNORE_PATTERNS:
        if fnmatch.fnmatch(os.path.basename(path), pattern):
            return True
    return False

def check_code_with_llm(file_path, code_content):
    """
    LLMにコードを送信し、認証情報が含まれていないか判定する
    """
    # プロンプトエンジニアリング: 役割を与え、出力形式を固定する
    system_prompt = (
        "あなたはセキュリティ監査の専門家です。渡されたコード内に「ハードコードされた機密情報」"
        "(パスワード、APIキー、JWTトークンなど)が含まれていないか分析してください。\n"
        "\n"
        "【判定基準】\n"
        "1. 危険(検出対象): ランダム性の高い文字列や具体的な認証情報が、変数に直接代入されている場合。\n"
        "   (例: api_key = 'sk-12345abcde...', token = 'eyJhbGciOi...')\n"
        "2. 安全(無視): 環境変数からの読み込み、空文字、明らかにダミーと分かるプレースホルダー。\n"
        "   (例: os.getenv('KEY'), pass = '', token = '<YOUR_TOKEN_HERE>')\n"
        "\n"
        "【出力形式】\n"
        "結果は以下のJSONフォーマットのみを出力してください。Markdownのコードブロックや解説は一切不要です。\n"
        "{\n"
        "  \"detected\": true または false,\n"
        "  \"offending_code\": \"問題箇所として特定したコード断片(元のソースコードからスペースを含め一字一句変えずに抽出すること)\",\n"
        "  \"reason\": \"検出理由(日本語で簡潔に)\"\n"
        "}"
    )


    prompt = f"File: {file_path}\n\nCode:\n```\n{code_content}\n```"

    try:
        response = requests.post(OLLAMA_API_URL, json={
            "model": MODEL_NAME,
            "prompt": prompt,
            "system": system_prompt,
            "stream": False,
            "format": "json" # JSONモードを強制
        })
        response.raise_for_status()
        result = response.json().get("response", "")
        return json.loads(result)
    except Exception as e:
        print(f"Error checking {file_path}: {e}")
        return {"detected": False, "error": str(e)}

def main():
    print(f"🔒 Starting Security Scan using {MODEL_NAME}...")
    
    issues_found = 0
    
    for root, dirs, files in os.walk(TARGET_DIR):
        # 除外ディレクトリのスキップ
        dirs[:] = [d for d in dirs if not is_ignored(d)]
        
        for file in files:
            if is_ignored(file):
                continue
                
            file_path = os.path.join(root, file)
            
            try:
                # テキストファイルとして読み込み
                with open(file_path, "r", encoding="utf-8") as f:
                    content = f.read()
                
                # コンテキストウィンドウ節約のため、極端に大きなファイルはスキップするか切り詰める
                if len(content) > 10000: 
                    print(f"⚠️  Skipping large file: {file_path}")
                    continue

                print(f"Testing: {file_path} ...", end="\r")
                
                result = check_code_with_llm(file_path, content)
                
                if result.get("detected"):
                    snippet = result.get("offending_code", "")
                    reason = result.get("reason", "")
                                
                    # コンソールへのPrintデバッグ出力(人間用)
                    print(f"\n[!] ALERT   : {file_path}")
                    
                    # 複数行のコードもきれいにインデントして表示する処理
                    code_lines = snippet.strip().split('\n')
                    if code_lines:
                        print(f"    Code    : {code_lines[0]}")
                        for line in code_lines[1:]:
                            print(f"              {line}")
                            
                    print(f"    Reason  : {reason}")

                    issues_found += 1
                    
            except UnicodeDecodeError:
                # バイナリファイル等はスキップ
                continue

    print("-" * 30)
    if issues_found == 0:
        print("✅ No hardcoded credentials detected.")
    else:
        print(f"🚨 Scan finished. {issues_found} potential issues found.")

if __name__ == "__main__":
    main()

以下はテスト用に読み込ませるコードです。
src ディレクトリを作成し、以下のファイル名で保存してください。

src/safe_example.py
import os

# ---------------------------------------------------
# 【OK】検出されてはいけないパターン(安全)
# ---------------------------------------------------

# 1. 環境変数からの読み込み(これは安全)
api_key = os.getenv("STRIPE_API_KEY")

# 2. 空文字や初期化(安全)
password_cache = ""

# 3. プレースホルダー(開発用のダミー文字列と文脈でわかるもの)
#    ※ LLMによっては迷う場合もありますが、理想はスルー
google_client_id = "<YOUR_CLIENT_ID_HERE>"

# 4. 設定ファイルからのロード関数呼び出し
db_pass = load_config("database.password")

src/detected_example.py
import os

# ---------------------------------------------------
# 【NG】検出されるべきパターン(ハードコード)
# ---------------------------------------------------

# 1. AWSキーに近い文字列
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

# 2. ランダム性の高い文字列を直接代入
DB_PASSWORD = "x8z9#mK2$pL0aQw!sT"

# 3. webhookに渡すURLの例
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"

# 4. JWTトークンの例
config_val = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"

依存ライブラリをインストールしておきます。

$ pip install requests

3. 実行結果

このスクリプトを実行すると、以下のように文脈を読んだ上で指摘をしてくれます。

# クレデンシャルが含まれる場合の例
$ python3 scan_script.py 
🔒 Starting Security Scan using qwen2.5-coder:7b...
Testing: ./src/detected_example.py ...
[!] ALERT   : ./src/detected_example.py
    Code    : AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
              AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
              DB_PASSWORD = "x8z9#mK2$pL0aQw!sT"
              SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
              config_val = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
    Reason  : ハードコードされた機密情報(AWSキー、データベースパスワード、Slackのwebhook URL、JWTトークン)が直接代入されています。
------------------------------ ...
🚨 Scan finished. 1 potential issues found.
# クレデンシャルが含まれない場合の例
$ python3 scan_script.py 

🔒 Starting Security Scan using qwen2.5-coder:7b...
------------------------------ ...
✅ No hardcoded credentials detected.

4. まとめ

ローカルLLM(Ollama + qwen2.5-coder)を使うことで、外部にデータを出さずにAIベースでのセキュリティチェックが可能になりました。
正規表現では難しい「文脈判断」により、誤検知を減らす効果が期待できます。
あくまでAIによる監査補助ですが、オフライン上でもAIを活用する方法の例を記事にしてみました。

今回はシンプルなスクリプトでしたが、これを MCP (Model Context Protocol) サーバーとして実装すれば、Claude DesktopやCursorから『このリポジトリのセキュリティチェックをして』と頼むだけで実行できるようになります。興味がある方はぜひ拡張に挑戦してみてください。

5. おわりに

最後まで読んでいただきありがとうございました!明日のアドベントカレンダーもお楽しみに!

また、インティメート・マージャーでは、新卒から中途採用まで幅広く採用募集中です!
記事を読んで弊社に興味を持ってくれた方は、下記より採用情報をチェック!

弊社では生成AIの活用を推進しております。この記事も生成AIを利用して作成しました。
(目安ですが、本記事では生成されたコードおよび文章の70%程度を利用しています)

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?