nucleiのテンプレートからadminパネルのURLを得たりshodan検索したりするツールを作った


Webアプリごとの`login`とか`admin`パネルとかのデフォルトURLをいちいち探すのが面倒だなあと思っていたのだけど、とりあえず `nuclei` のテンプレート使えば早いんじゃね？ということが頭に浮かんだので作った。
何かに書いておかないと忘れるのでメモ。

## 📁 フォルダ構成

```
product-finder/
├── app.py                       # アプリ本体
├── utils/
│   └── extract_templates.py     # nuclei テンプレートから情報抽出するスクリプト
├── data/
│   └── nuclei_data.json         # extract_templates.py が生成するデータファイル
├── nuclei-templates/            # nuclei テンプレートリポジトリ（自動 clone される）
│   └── ...
├── templates/
│   ├── index.html               # 検索・一覧表示用テンプレート
│   └── show_template.html       # YAML ファイル内容表示用
```

---

## 🛠 セットアップ手順

### 1. 依存ライブラリインストール

```bash
$ pip install -r requirements.txt
```

---

### 2. nuclei-templates のクローンとデータ生成

```bash
python utils/extract_templates.py
```

これで `nuclei-templates/` が clone され、YAML ファイルから管理系テンプレートを抽出し、`data/nuclei_data.json` に保存される。

---

### 3. Flask アプリ起動

```bash
python app.py
```

ブラウザで [http://localhost:5004/](http://localhost:5004/) にアクセス。

---

## 🖥 使い方

### 🔍 `/`  
初期画面。検索フォームだけが表示される。

### 🔎 `/search?q=jenkins`  
検索キーワードにマッチするプロダクトを表示（ページネーション付き）

### 📃 `/all`  
すべてのテンプレートを表示（50件ずつページ分割）

---

## 🔗 各テンプレートの情報

| 表示される情報 | 説明 |
|----------------|------|
| プロダクト名 | nuclei テンプレートから自動抽出 |
| パス | `http.path` 情報（管理画面 URL パターン） |
| Shodan | `metadata.shodan-query` があればそれを使用、なければ `http.title` ベースで生成 |
| Google | 自動生成された `inurl:` クエリ |
| テンプレートを見る | YAML の中身をブラウザで整形表示 |

---

## できること
- 各プロダクトのデフォルトの`/admin`とか`/login`がわかる
- そこを探す検索URLが自動でできる

## やってないこと

- データの並べ替え
- 変更したいならsqliteとか使った方がよさそう

---

## 以下ソースコードと画像

### app.py

```python
from flask import Flask, render_template, request
import os, json
import math

app = Flask(__name__)

from flask import send_from_directory

@app.route('/nuclei-templates/<path:filename>')
def serve_nuclei_templates(filename):
    return send_from_directory('nuclei-templates', filename)
    
app = Flask(__name__)

@app.route('/show_template/<path:filename>')
def show_template(filename):
    file_path = os.path.join('nuclei-templates', filename)
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
    return render_template('show_template.html', content=content)

# 読み込み済みのデータファイル (extract_templates.py で生成)

with open("data/nuclei_data.json", "r", encoding="utf-8") as f:
    DATA = json.load(f)

PER_PAGE = 50

@app.route("/")
def index():
    # クエリがない場合は空表示
    return render_template("index.html", results=[], query="", total=0, page=1, pages=0)

@app.route("/search")
def search():
    query = request.args.get("q", "").lower()
    page = int(request.args.get("page", 1))
    matched = [d for d in DATA if query in d["product"].lower()]
    total = len(matched)
    pages = math.ceil(total / PER_PAGE)
    results = matched[(page - 1) * PER_PAGE : page * PER_PAGE]
    return render_template("index.html", results=results, query=query, total=total, page=page, pages=pages)

@app.route("/all")
def show_all():
    page = int(request.args.get("page", 1))
    total = len(DATA)
    pages = math.ceil(total / PER_PAGE)
    results = DATA[(page - 1) * PER_PAGE : page * PER_PAGE]
    return render_template("index.html", results=results, query="", total=total, page=page, pages=pages)

if __name__ == "__main__":
    app.run(debug=True,port=5004)

```

## utils/extract_templates.py

```python
import os
import yaml
import json
from urllib.parse import quote
import subprocess

def extract_product_name(name):
    # 最初の単語だけを抜き出してプロダクト名にする
    return name.strip().split()[0]

def clone_templates(repo_url="https://github.com/projectdiscovery/nuclei-templates.git", path="nuclei-templates"):
    if not os.path.exists(path):
        print(f"Cloning {repo_url} into {path} ...")
        subprocess.run(["git", "clone", "--depth", "1", repo_url, path])
    else:
        print(f"Templates already exist in {path}")

def extract_templates(base_dir="nuclei-templates", output_file="data/nuclei_data.json"):
    entries = []

    for root, _, files in os.walk(base_dir):
        for file in files:
            if not file.endswith(".yaml"):
                continue
            full_path = os.path.join(root, file)
            try:
                with open(full_path, "r", encoding="utf-8") as f:
                    yml = yaml.safe_load(f)
                    name = yml.get("info", {}).get("name", "Unknown")
                    product = extract_product_name(name)
                    tags = yml.get("info", {}).get("tags", [])
                    if isinstance(tags, str):
                        tags = [t.strip() for t in tags.split(",")]

                    if not any(tag in tags for tag in ["panel", "admin", "login", "dashboard"]):
                        continue  # 管理系っぽいテンプレだけに絞る

                    # requests, http, dnsなど複数形式に対応
                    for section_key in ["requests", "http"]:
                        reqs = yml.get(section_key, [])
                        if not reqs:
                            continue
                        for req in reqs:
                            paths = req.get("path", [])
                            if isinstance(paths, str):
                                paths = [paths]
                            for path in paths:
                                metadata = yml.get("info", {}).get("metadata", {})
                                raw_shodan_q = metadata.get("shodan-query")
                                shodan_q = raw_shodan_q if isinstance(raw_shodan_q, str) else f'http.title:"{product}"'

                                google_q = f'inurl:{path} intitle:"{product}"'
                                entries.append({
                                    "product": product,
                                    "path": path,
                                    "tags": tags,
                                    "template": os.path.relpath(full_path, base_dir),
                                    "template_file": os.path.basename(full_path),  # ここでテンプレートファイル名を追加
                                    "shodan_url": f"https://www.shodan.io/search?query={quote(shodan_q)}",
                                    "google_url": f"https://www.google.com/search?q={quote(google_q)}"
                                })
            except Exception as e:
                print(f"Error parsing {full_path}: {e}")

    os.makedirs(os.path.dirname(output_file), exist_ok=True)
    with open(output_file, "w", encoding="utf-8") as out:
        json.dump(entries, out, indent=2, ensure_ascii=False)
    print(f"Extracted {len(entries)} entries and saved to {output_file}")

if __name__ == "__main__":
    clone_templates()
    extract_templates()


```

![スクリーンショット 2025-04-09 17.36.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547510/d9fc9b28-718a-4bda-8856-1645a1fef0b3.png)


## templates/index.html
```html 
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Nuclei 管理パネル検索</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container mt-4">
    <h2>プロダクト管理パネル検索</h2>

    <form method="get" action="/search" class="mb-3 d-flex">
        <input name="q" class="form-control me-2" value="{{ query }}" placeholder="プロダクト名 (例: Jenkins)">
        <button class="btn btn-primary" type="submit">検索</button>
    </form>

    <p>
        <a href="/all" class="btn btn-secondary btn-sm">全件表示</a>
        {% if total %}
        <span class="ms-3">ヒット件数: {{ total }} 件</span>
        {% endif %}
    </p>

    {% if results %}
    <table class="table table-bordered table-striped">
        <thead>
            <tr>
                <th>プロダクト</th>
                <th>URL</th>
                <th>Shodan</th>
                <th>Google</th>
                <th>テンプレート</th>
            </tr>
        </thead>
        <tbody>
            {% for r in results %}
            <tr>
                <td>{{ r.product }}</td>
                <td>{{ r.path | replace("{{BaseURL}}", "") }}</td>
                <td><a href="{{ r.shodan_url }}" target="_blank">Shodan</a></td>
                <td><a href="{{ r.google_url | replace('%7B%7BBaseURL%7D%7D', '') }}" target="_blank">Google</a></td>
                <td><a href="{{ url_for('show_template', filename=r.template) }}" target="_blank">テンプレートを見る</a></td>
            </tr>
            {% endfor %}
        </tbody>
    </table>

    <!-- ページネーション -->
    {% if pages > 1 %}
    <nav>
        <ul class="pagination">
            {% for p in range(1, pages + 1) %}
            <li class="page-item {% if p == page %}active{% endif %}">
                <a class="page-link" href="?q={{ query }}&page={{ p }}">{{ p }}</a>
            </li>
            {% endfor %}
        </ul>
    </nav>
    {% endif %}
    {% else %}
        <p class="text-muted">検索キーワードを入力してください。</p>
    {% endif %}
</body>
</html>
```

![スクリーンショット 2025-04-09 17.36.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547510/5e3838f6-d947-4cad-951b-03d94a63ba61.png)


## templates/show_template.html
```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>テンプレート内容</title>
  <!-- Highlight.js スタイル -->
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
  <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
  <script>hljs.highlightAll();</script>
  <style>
    body {
      font-family: sans-serif;
      margin: 2rem;
    }
    pre {
      background-color: #f0f0f0;
      padding: 1rem;
      border-radius: 8px;
      overflow-x: auto;
    }
  </style>
</head>
<body>
  <h1>テンプレート内容</h1>
  <pre><code class="language-yaml">{{ content | e }}</code></pre>
</body>
</html>
```

## requirements.txt

```
blinker==1.9.0
click==8.1.8
Flask==3.1.0
gitdb==4.0.12
GitPython==3.1.44
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
PyYAML==6.0.2
smmap==5.0.2
Werkzeug==3.1.3
```