GPU OllamaコンテナベースのセキュリティAIウェブアプリの作成

Flask + Ollama + NVD APIでローカルCVEアナリストを作成する

このチュートリアルでは、すでにGPUベースで実行中のOllamaコンテナを活用して、独自のウェブアプリケーションを作成します。

単に「LLMに質問するチャットボット」ではなく、外部APIから実際のデータを取得し、そのデータをローカルLLMが分析するように構成します。例として作成するアプリは、ローカルCVEアナリストです。

ユーザーがCVE IDまたはキーワードを入力すると、FlaskアプリがNVD APIから脆弱性情報を照会し、Ollamaで実行中のローカルLLMがその脆弱性を日本語で要約・分析する構造です。

Ollamaの/api/chatエンドポイントは、model、messagesを入力として受け取り、対話型応答を生成でき、基本的にストリーミング応答を使用します。チュートリアルでは、Flaskで処理しやすくするためにstream: falseを使用します。


1. 完成するアプリの構造

今回作成する構造は以下の通りです。

사용자 브라우저
    ↓
Flask Web UI
    ↓
Flask Backend
    ├── NVD CVE API 호출
    └── Ollama API 호출
            ↓
      로컬 GPU LLM 응답 생성
            ↓
사용자 화면에 CVE 원본 정보 + AI 분석 결과 출력

重要なのは、Flaskサーバーが中間で2つの役割を果たす点です。

第一に、ユーザーが入力したCVE IDまたはキーワードでNVD APIを呼び出します。

第二に、NVDから取得した脆弱性情報をOllamaに渡し、分析結果を受け取ります。

このチュートリアルでは、LLMが直接APIを呼び出すのではなく、Flaskが明示的にAPIを呼び出した後、その結果をLLMに渡します。これにより、初心者でも流れを理解しやすく、実習中のエラーも減らすことができます。


2. 事前条件

このチュートリアルは、以下の状態を前提とします。

1. Ollama 컨테이너가 이미 실행 중이다.
2. Ollama 컨테이너가 GPU를 사용하도록 구성되어 있다.
3. Ollama API 포트 11434에 접근할 수 있다.
4. 사용할 모델이 이미 pull 되어 있거나, pull 할 수 있다.

例えば、ホストで次のコマンドが正常に動作する必要があります。

curl http://localhost:11434/api/tags

応答例は次のようになります。

{
  "models": [
    {
      "name": "llama3.1:latest"
    }
  ]
}

簡単なチャット呼び出しも確認してみます。

curl http://localhost:11434/api/chat 
  -H "Content-Type: application/json" 
  -d '{
    "model": "llama3.1",
    "messages": [
      {
        "role": "user",
        "content": "Ollama가 무엇인지 한 문장으로 설명해줘."
      }
    ],
    "stream": false
  }'

stream: falseを使用すると、応答が複数の断片に分割されず、単一のJSONとして返されるため、Flaskアプリで扱いやすくなります。


3. プロジェクトディレクトリの作成

まず、プロジェクトディレクトリを作成します。

mkdir ollama-flask-cve-app
cd ollama-flask-cve-app

全体の構造は以下の通りです。

ollama-flask-cve-app/
├── app.py
├── requirements.txt
├── Dockerfile
├── docker-compose.app.yml
├── templates/
│   └── index.html
└── static/
    └── style.css

FlaskはデフォルトでHTMLテンプレートをtemplatesディレクトリで探し、render_template()関数を使用してHTMLページをレンダリングします。


4. Pythonパッケージの定義

requirements.txtファイルを作成します。

Flask==3.1.1
requests==2.32.4

FlaskはウェブUIとルーティングを担当し、requestsはOllama APIとNVD APIを呼び出すために使用します。


5. Flaskバックエンドの作成

次に、app.pyを作成します。

import os
import re
from typing import Any, Dict, List, Optional

import requests
from flask import Flask, render_template, request

app = Flask(__name__)

# Ollamaコンテナがホストの11434ポートで公開されている場合はlocalhostを使用
# Flaskアプリもコンテナとして実行し、Ollamaコンテナと同じネットワークに接続する場合
# 例: http://lab-ollama:11434/api/chat または http://ollama:11434/api/chat
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434/api/chat")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1")

# NVD CVE API 2.0
NVD_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"

# オプション: NVD APIキー
# APIキーがある場合は、環境変数として指定します。
# export NVD_API_KEY="your-api-key"
NVD_API_KEY = os.getenv("NVD_API_KEY", "")

# キーワード検索時に取得する最大CVE数
NVD_RESULTS_PER_PAGE = int(os.getenv("NVD_RESULTS_PER_PAGE", "5"))

def is_cve_id(keyword: str) -> bool:
    """
    입력값이 CVE ID 형식인지 확인합니다.
    예: CVE-2024-3094
    """
    return bool(re.fullmatch(r"CVE-d{4}-d{4,}", keyword.strip(), re.IGNORECASE))

def call_nvd_api(keyword: str) -> Dict[str, Any]:
    """
    NVD API에서 CVE 정보를 가져옵니다.

    CVE ID가 입력되면 cveIds 파라미터를 사용하고,
    일반 키워드가 입력되면 keywordSearch 파라미터를 사용합니다.
    """
    keyword = keyword.strip()

    if is_cve_id(keyword):
        params = {
            "cveIds": keyword.upper()
        }
    else:
        params = {
            "keywordSearch": keyword,
            "resultsPerPage": NVD_RESULTS_PER_PAGE
        }

    headers = {}

    if NVD_API_KEY:
        headers["apiKey"] = NVD_API_KEY

    response = requests.get(
        NVD_API_URL,
        params=params,
        headers=headers,
        timeout=30
    )

    response.raise_for_status()
    return response.json()

def get_english_description(cve: Dict[str, Any]) -> str:
    """
    CVE 설명 중 영어 설명을 우선 추출합니다.
    """
    descriptions = cve.get("descriptions", [])

    for item in descriptions:
        if item.get("lang") == "en":
            return item.get("value", "")

    if descriptions:
        return descriptions[0].get("value", "")

    return ""

def get_cvss_info(cve: Dict[str, Any]) -> Dict[str, str]:
    """
    CVSS 정보를 추출합니다.
    CVSS v4.0, v3.1, v3.0, v2 순서로 확인합니다.
    """
    metrics = cve.get("metrics", {})

    metric_order = [
        "cvssMetricV40",
        "cvssMetricV31",
        "cvssMetricV30",
        "cvssMetricV2",
    ]

    for metric_key in metric_order:
        metric_items = metrics.get(metric_key)

        if not metric_items:
            continue

        metric = metric_items[0]
        cvss_data = metric.get("cvssData", {})

        return {
            "version": str(cvss_data.get("version", metric_key)),
            "score": str(cvss_data.get("baseScore", "UNKNOWN")),
            "severity": str(
                metric.get("baseSeverity")
                or cvss_data.get("baseSeverity")
                or "UNKNOWN"
            ),
            "vector": str(cvss_data.get("vectorString", "UNKNOWN")),
        }

    return {
        "version": "UNKNOWN",
        "score": "UNKNOWN",
        "severity": "UNKNOWN",
        "vector": "UNKNOWN",
    }

def get_cwe_list(cve: Dict[str, Any]) -> List[str]:
    """
    CWE 목록을 추출합니다.
    """
    weaknesses = cve.get("weaknesses", [])
    cwe_list = []

    for weakness in weaknesses:
        for desc in weakness.get("description", []):
            value = desc.get("value")
            if value:
                cwe_list.append(value)

    return sorted(set(cwe_list))

def get_reference_urls(cve: Dict[str, Any], limit: int = 5) -> List[str]:
    """
    참고 URL을 일부만 추출합니다.
    """
    references = cve.get("references", {}).get("referenceData", [])
    urls = []

    for ref in references[:limit]:
        url = ref.get("url")
        if url:
            urls.append(url)

    return urls

def extract_cve_items(nvd_data: Dict[str, Any]) -> List[Dict[str, Any]]:
    """
    NVD API 응답에서 화면과 프롬프트에 사용할 정보만 추출합니다.
    """
    vulnerabilities = nvd_data.get("vulnerabilities", [])
    result = []

    for item in vulnerabilities:
        cve = item.get("cve", {})
        cvss = get_cvss_info(cve)

        result.append(
            {
                "id": cve.get("id", "UNKNOWN"),
                "published": cve.get("published", "UNKNOWN"),
                "last_modified": cve.get("lastModified", "UNKNOWN"),
                "status": cve.get("vulnStatus", "UNKNOWN"),
                "description": get_english_description(cve),
                "cvss_version": cvss["version"],
                "cvss_score": cvss["score"],
                "severity": cvss["severity"],
                "vector": cvss["vector"],
                "cwe": get_cwe_list(cve),
                "references": get_reference_urls(cve),
            }
        )

    return result

def format_cve_items_for_prompt(cve_items: List[Dict[str, Any]]) -> str:
    """
    Ollama에게 전달할 텍스트를 만듭니다.
    원본 JSON 전체를 넘기지 않고 필요한 필드만 전달합니다.
    """
    if not cve_items:
        return "검색 결과가 없습니다."

    lines = []

    for item in cve_items:
        cwe_text = ", ".join(item["cwe"]) if item["cwe"] else "UNKNOWN"
        ref_text = "
".join(item["references"]) if item["references"] else "NONE"

        lines.append(
            f"""
CVE ID: {item["id"]}
Published: {item["published"]}
Last Modified: {item["last_modified"]}
Status: {item["status"]}
Severity: {item["severity"]}
CVSS Version: {item["cvss_version"]}
CVSS Score: {item["cvss_score"]}
CVSS Vector: {item["vector"]}
CWE: {cwe_text}
Description:
{item["description"]}

References:
{ref_text}
"""
        )

    return "
---
".join(lines)

def ask_ollama(cve_text: str) -> str:
    """
    Ollama API를 호출해 CVE 분석 결과를 생성합니다.
    """
    prompt = f"""
너는 보안 분석가다.
아래 CVE 정보를 바탕으로 한국어로 분석해라.

반드시 지켜야 할 규칙:
1. 제공된 CVE 정보에 근거해서만 설명한다.
2. 원문에 없는 공격 절차를 과장해서 만들지 않는다.
3. 초보자도 이해할 수 있게 설명한다.
4. 운영자 관점의 대응 방안을 제시한다.
5. 출력은 다음 형식을 따른다.

출력 형식:
## 요약
## 취약점 설명
## 악용 가능성
## 위험도 판단
## 운영자 대응 방안
## 참고할 점

CVE 정보:
{cve_text}
"""

    payload = {
        "model": OLLAMA_MODEL,
        "messages": [
            {
                "role": "user",
                "content": prompt
            }
        ],
        "stream": False,
        "options": {
            "temperature": 0.2
        }
    }

    response = requests.post(
        OLLAMA_URL,
        json=payload,
        timeout=180
    )

    response.raise_for_status()
    data = response.json()

    return data.get("message", {}).get("content", "")

@app.route("/", methods=["GET", "POST"])
def index():
    """
    메인 페이지입니다.
    GET 요청이면 검색 폼만 보여주고,
    POST 요청이면 NVD API 조회 후 Ollama 분석 결과를 보여줍니다.
    """
    query = ""
    cve_items = []
    cve_prompt_text = ""
    analysis_result = ""
    error = ""

    if request.method == "POST":
        query = request.form.get("query", "").strip()

        if not query:
            error = "CVE ID 또는 검색 키워드를 입력하세요."
        else:
            try:
                nvd_data = call_nvd_api(query)
                cve_items = extract_cve_items(nvd_data)
                cve_prompt_text = format_cve_items_for_prompt(cve_items)

                if cve_items:
                    analysis_result = ask_ollama(cve_prompt_text)
                else:
                    error = "NVD API에서 검색 결과를 찾지 못했습니다."

            except requests.exceptions.HTTPError as e:
                error = f"HTTP 오류가 발생했습니다: {e}"
            except requests.exceptions.Timeout:
                error = "요청 시간이 초과되었습니다. 모델이 너무 크거나 API 응답이 느릴 수 있습니다."
            except requests.exceptions.RequestException as e:
                error = f"API 호출 중 오류가 발생했습니다: {e}"
            except Exception as e:
                error = f"처리 중 예상하지 못한 오류가 발생했습니다: {e}"

    return render_template(
        "index.html",
        query=query,
        model=OLLAMA_MODEL,
        ollama_url=OLLAMA_URL,
        cve_items=cve_items,
        cve_prompt_text=cve_prompt_text,
        analysis_result=analysis_result,
        error=error,
    )

if __name__ == "__main__":
    app.run(
        host="0.0.0.0",
        port=5000,
        debug=True
    )

NVD CVE API 2.0の基本URLはです。単一のCVE照会にはcveIdsパラメータを使用でき、キーワード検索にはkeywordSearchを使用できます。resultsPerPageとstartIndexは結果のページネーションに使用されます。

また、NVD APIキーはリクエストヘッダーにapiKey:{key value}の形式で渡され、APIキーがない場合は30秒あたり5回、APIキーがある場合は30秒あたり50回の制限が適用されます。


6. HTML UIの作成

次に、templates/index.htmlファイルを作成します。

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <title>Local CVE Analyst</title>

  <link
    rel="stylesheet"
    href="{{ url_for('static', filename='style.css') }}"
  />
</head>
<body>
  <main class="page">
    <section class="hero">
      <div class="badge">Ollama + Flask + NVD API</div>
      <h1>Local CVE Analyst</h1>
      <p>
        GPU 기반 Ollama 컨테이너와 NVD API를 연동해
        CVE 정보를 로컬 LLM으로 분석하는 보안 AI 웹앱입니다.
      </p>
    </section>

    <section class="panel">
      <form method="POST" class="search-form">
        <label for="query">CVE ID 또는 검색 키워드</label>

        <div class="input-row">
          <input
            id="query"
            name="query"
            type="text"
            value="{{ query }}"
            placeholder="예: CVE-2024-3094 또는 openssl"
            autocomplete="off"
          />

          <button type="submit">
            분석하기
          </button>
        </div>
      </form>

      <div class="runtime-info">
        <span>Model: {{ model }}</span>
        <span>Ollama API: {{ ollama_url }}</span>
      </div>
    </section>

    {% if error %}
    <section class="alert">
      {{ error }}
    </section>
    {% endif %}

    {% if cve_items %}
    <section class="grid">
      <div class="panel">
        <h2>NVD 검색 결과</h2>

        {% for item in cve_items %}
        <article class="cve-card">
          <div class="cve-header">
            <h3>{{ item.id }}</h3>
            <span class="severity">{{ item.severity }}</span>
          </div>

          <dl>
            <div>
              <dt>CVSS</dt>
              <dd>{{ item.cvss_score }} / {{ item.cvss_version }}</dd>
            </div>
            <div>
              <dt>Status</dt>
              <dd>{{ item.status }}</dd>
            </div>
            <div>
              <dt>Published</dt>
              <dd>{{ item.published }}</dd>
            </div>
            <div>
              <dt>Last Modified</dt>
              <dd>{{ item.last_modified }}</dd>
            </div>
          </dl>

          <p class="description">
            {{ item.description }}
          </p>

          {% if item.cwe %}
          <div class="chips">
            {% for cwe in item.cwe %}
            <span>{{ cwe }}</span>
            {% endfor %}
          </div>
          {% endif %}
        </article>
        {% endfor %}
      </div>

      <div class="panel">
        <h2>Ollama 분석 결과</h2>

        {% if analysis_result %}
        <pre class="analysis">{{ analysis_result }}</pre>
        {% else %}
        <p class="muted">분석 결과가 없습니다.</p>
        {% endif %}
      </div>
    </section>

    <section class="panel">
      <h2>LLM에 전달한 요약 데이터</h2>
      <pre class="raw">{{ cve_prompt_text }}</pre>
    </section>
    {% endif %}
  </main>
</body>
</html>

ここで重要なのは、analysis_resultをsafe処理しないことです。LLM応答や外部API応答をHTMLとして信頼すると、XSS問題が発生する可能性があります。そのため、

タグとCSSのwhite-space処理を使用して、改行のみを自然に表示します。


7. CSSの作成

static/style.cssファイルを作成します。

:root {
  --bg: #f3f4f6;
  --panel: #ffffff;
  --text: #111827;
  --muted: #6b7280;
  --border: #e5e7eb;
  --primary: #111827;
  --primary-hover: #374151;
  --danger-bg: #fee2e2;
  --danger-text: #991b1b;
  --code-bg: #0f172a;
  --code-text: #e5e7eb;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    "Noto Sans KR",
    Arial,
    sans-serif;
  background: var(--bg);
  color: var(--text);
}

.page {
  width: 1120px;
  max-width: 94%;
  margin: 40px auto;
}

.hero {
  margin-bottom: 24px;
}

.badge {
  display: inline-block;
  padding: 6px 10px;
  margin-bottom: 12px;
  border-radius: 999px;
  background: #e5e7eb;
  color: #374151;
  font-size: 13px;
  font-weight: 700;
}

.hero h1 {
  margin: 0 0 12px;
  font-size: 42px;
  letter-spacing: -0.04em;
}

.hero p {
  margin: 0;
  color: var(--muted);
  font-size: 18px;
  line-height: 1.6;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 24px;
  margin-bottom: 20px;
  box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
}

.search-form label {
  display: block;
  margin-bottom: 10px;
  font-weight: 800;
}

.input-row {
  display: flex;
  gap: 10px;
}

.input-row input {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  font-size: 16px;
}

.input-row button {
  padding: 0 22px;
  border: none;
  border-radius: 12px;
  background: var(--primary);
  color: white;
  font-size: 16px;
  font-weight: 800;
  cursor: pointer;
}

.input-row button:hover {
  background: var(--primary-hover);
}

.runtime-info {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 14px;
  color: var(--muted);
  font-size: 13px;
}

.runtime-info span {
  padding: 6px 10px;
  background: #f9fafb;
  border: 1px solid var(--border);
  border-radius: 999px;
}

.alert {
  padding: 16px;
  margin-bottom: 20px;
  border-radius: 14px;
  background: var(--danger-bg);
  color: var(--danger-text);
  font-weight: 700;
}

.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  align-items: start;
}

h2 {
  margin-top: 0;
  margin-bottom: 16px;
  font-size: 22px;
}

.cve-card {
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: 14px;
  margin-bottom: 14px;
  background: #f9fafb;
}

.cve-header {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  align-items: center;
  margin-bottom: 12px;
}

.cve-header h3 {
  margin: 0;
}

.severity {
  padding: 5px 9px;
  border-radius: 999px;
  background: #111827;
  color: #ffffff;
  font-size: 12px;
  font-weight: 800;
}

dl {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin: 0 0 14px;
}

dt {
  color: var(--muted);
  font-size: 12px;
}

dd {
  margin: 2px 0 0;
  font-weight: 700;
  font-size: 14px;
}

.description {
  line-height: 1.6;
  color: #374151;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.chips span {
  padding: 5px 8px;
  border-radius: 999px;
  background: #e5e7eb;
  color: #374151;
  font-size: 12px;
  font-weight: 700;
}

pre {
  margin: 0;
  white-space: pre-wrap;
  word-break: break-word;
  line-height: 1.6;
}

.analysis {
  min-height: 300px;
  color: var(--text);
  font-family: inherit;
}

.raw {
  padding: 16px;
  border-radius: 12px;
  background: var(--code-bg);
  color: var(--code-text);
  font-size: 13px;
}

.muted {
  color: var(--muted);
}

@media (max-width: 900px) {
  .grid {
    grid-template-columns: 1fr;
  }

  .input-row {
    flex-direction: column;
  }

  .input-row button {
    padding: 14px 22px;
  }

  .hero h1 {
    font-size: 34px;
  }
}

8. ローカルでの実行

パッケージをインストールします。

pip install -r requirements.txt

Ollama API URLとモデル名を指定します。

export OLLAMA_URL="http://localhost:11434/api/chat"
export OLLAMA_MODEL="llama3.1"

NVD APIキーがある場合は、オプションで指定します。

export NVD_API_KEY="your-nvd-api-key"

Flaskアプリを実行します。

python app.py

ブラウザで次のアドレスにアクセスします。

http://localhost:5000

検索バーに次のように入力してみます。

CVE-2024-3094

または、キーワードで検索することもできます。

openssl

画面には主に3つの情報が表示されます。

1. NVD API에서 가져온 CVE 기본 정보
2. Ollama가 생성한 한국어 취약점 분석
3. Ollama에게 실제로 전달한 요약 데이터

9. コンテナでの実行

Flaskアプリもコンテナで実行したい場合は、Dockerfileを作成します。

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

アプリイメージをビルドします。

docker build -t ollama-flask-cve-app:latest .

Ollamaコンテナがホストの11434ポートを公開している場合、Dockerでは状況に応じて次のアドレスを使用できます。

http://host.docker.internal:11434/api/chat

ただし、Linuxサーバーや実習環境ではhost.docker.internalが常に保証されるとは限りません。最もクリーンな方法は、OllamaコンテナとFlaskアプリコンテナを同じDockerネットワークに入れることです。

例えば、Ollamaコンテナの名前がlab-ollamaだと仮定します。

docker network create ollama-net
docker network connect ollama-net lab-ollama

次に、Flaskアプリコンテナを同じネットワークに接続します。

docker run --rm -it 
  --name local-cve-analyst 
  --network ollama-net 
  -p 5000:5000 
  -e OLLAMA_URL="http://lab-ollama:11434/api/chat" 
  -e OLLAMA_MODEL="llama3.1" 
  ollama-flask-cve-app:latest

次に、ブラウザでアクセスします。

http://localhost:5000

10. Docker Composeの例

すでにOllamaコンテナが個別に実行中の場合、アプリのみをComposeで管理することもできます。

docker-compose.app.ymlファイルを作成します。

services:
  cve-app:
    build: .
    container_name: local-cve-analyst
    ports:
      - "5000:5000"
    environment:
      OLLAMA_URL: "http://lab-ollama:11434/api/chat"
      OLLAMA_MODEL: "llama3.1"
      NVD_RESULTS_PER_PAGE: "5"
      # NVD_API_KEY: "your-nvd-api-key"
    networks:
      - ollama-net

networks:
  ollama-net:
    external: true

実行します。

docker compose -f docker-compose.app.yml up --build

もしOllamaコンテナの名前がollamaであれば、次のように変更できます。

OLLAMA_URL: "http://ollama:11434/api/chat"

重要な点は、Flaskアプリがコンテナ内で実行されるとき、localhostはOllamaコンテナではなく、Flaskアプリコンテナ自身を意味するということです。そのため、アプリコンテナからOllamaコンテナにアクセスするには、同じネットワークに接続し、コンテナ名をホスト名のように使用する方法が最も理解しやすいです。


11. Podman環境で実行する場合

実習スクリプトがPodmanベースの場合も同様に構成できます。

Ollamaコンテナがlab-ollamaという名前で実行中であると仮定します。

podman network create ollama-net
podman network connect ollama-net lab-ollama

イメージをビルドします。

podman build -t ollama-flask-cve-app:latest .

アプリコンテナを実行します。

podman run --rm -it 
  --name local-cve-analyst 
  --network ollama-net 
  -p 5000:5000 
  -e OLLAMA_URL="http://lab-ollama:11434/api/chat" 
  -e OLLAMA_MODEL="llama3.1" 
  ollama-flask-cve-app:latest

ブラウザで次のアドレスにアクセスします。

http://localhost:5000

12. テストシナリオ

最初のテストはCVE ID単一検索です。

CVE-2024-3094

この場合、FlaskアプリはNVD APIにcveIds=CVE-2024-3094の形式でリクエストします。

2番目のテストはキーワード検索です。

openssl

この場合、FlaskアプリはNVD APIにkeywordSearch=opensslの形式でリクエストします。

3番目のテストは存在しないCVE IDです。

CVE-2099-0000

この場合、検索結果がないというメッセージが表示されるはずです。


13. よくあるエラー

13.1 Ollama API接続失敗

エラー例は以下の通りです。

Connection refused

確認すべき内容は以下の通りです。

curl http://localhost:11434/api/tags

ホストでは動作するのにアプリコンテナ内では動作しない場合、OLLAMA_URLが間違っている可能性が高いです。

Flaskアプリをローカルで実行する場合は、以下を使用します。

export OLLAMA_URL="http://localhost:11434/api/chat"

Flaskアプリをコンテナで実行し、Ollamaコンテナの名前がlab-ollamaの場合は、以下を使用します。

export OLLAMA_URL="http://lab-ollama:11434/api/chat"

13.2 モデルがないというエラー

Ollamaの応答でモデルがないと表示された場合、まずそのモデルをプルする必要があります。

ollama pull llama3.1

Ollamaがコンテナ内で実行中の場合は、次のように実行します。

docker exec -it lab-ollama ollama pull llama3.1

または、Podman環境では以下の通りです。

podman exec -it lab-ollama ollama pull llama3.1

13.3 NVD API 403または制限エラー

NVD APIは公開アクセスでもレート制限があります。APIキーがない場合は30秒あたり5回、APIキーがある場合は30秒あたり50回の制限が適用されます。

実習中に何度もボタンを押すと制限にかかる可能性があります。この場合、しばらく待ってから再度試してください。

運用レベルで作成するには、次の機能を追加することをお勧めします。

1. NVD API 응답 캐싱
2. 동일 검색어 반복 요청 제한
3. API Key 사용
4. 사용자별 요청 제한

13.4 応答時間が長すぎる場合

ローカルLLMモデルが大きいか、GPUメモリが不足している場合、応答時間が長くなる可能性があります。

この場合、以下を調整します。

1. 더 작은 모델 사용
2. NVD_RESULTS_PER_PAGE 값을 1~3으로 축소
3. 프롬프트 길이 축소
4. Ollama keep_alive 설정 조정

例えば、結果の数を3つに減らします。

export NVD_RESULTS_PER_PAGE="3"

14. このアプリの学習ポイント

この実習の核心は、「LLMアプリは単にチャットウィンドウを貼り付けるだけではない」という点です。

このアプリは、以下の概念をすべて含んでいます。

1. 웹 UI
2. Flask 백엔드
3. 외부 REST API 연동
4. 로컬 LLM API 호출
5. 프롬프트 구성
6. 컨테이너 네트워크
7. 보안 데이터 분석

特にセキュリティ教育では、この構造が有用です。CVE、CWE、CVSSのようなセキュリティデータを単に表示するだけでなく、LLMが運用者の視点からの説明に変換してくれるからです。


15. 次のステップ:ツール呼び出し構造への拡張

このチュートリアルでは、Flaskが最初にNVD APIを呼び出し、その結果をOllamaに渡しました。

사용자 입력
    ↓
Flask가 NVD API 호출
    ↓
Flask가 Ollama 호출
    ↓
최종 답변 출력

高度なバージョンでは、Ollamaのツール呼び出し機能を使用できます。Ollamaは、モデルがツールを呼び出し、その結果を応答に反映するツール呼び出し機能をサポートしています。

高度な構造は以下の通りです。

사용자 입력
    ↓
Ollama가 필요한 도구 판단
    ↓
Flask가 해당 도구 함수 실행
    ↓
도구 실행 결과를 Ollama에 다시 전달
    ↓
최종 답변 출력

ただし、最初からこの構造にすると説明が複雑になります。そのため、入門チュートリアルでは、今回のように「Flaskが外部APIを呼び出し、Ollamaは分析のみを担当する構造」がより適切です。


16. まとめ

このチュートリアルでは、GPUベースのOllamaコンテナがすでに準備されていることを前提に、Flaskを使用して実際のウェブUIを持つローカルLLMアプリケーションを作成しました。

完成したアプリは次の流れで動作します。

CVE ID 또는 키워드 입력
    ↓
NVD API에서 취약점 정보 조회
    ↓
필요한 필드만 추출
    ↓
Ollama 로컬 LLM에 분석 요청
    ↓
웹 화면에 원본 정보와 AI 분석 결과 출력

この例は、単純なチャットボットよりも実習価値が高いです。外部APIとローカルLLMを接続する基本的なパターンを示しているからです。このパターンを応用すれば、GitHubリポジトリアナライザー、ログ分析アシスタント、セキュリティチェックレポート生成器、内部文書検索型RAGアプリなどにも拡張できます。


Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です