オープンソースだけで本番運用できるSOCを構築した話 — Wazuh + DFIR-IRIS + 自社統合レイヤー

中堅企業向けに、Wazuh、DFIR-IRIS、Pythonで自作したミドルウェアで本番運用のSOCを構築した実装記録です。実際に効いた設計、つまずいた箇所、そして本当に重要だった技術判断を整理します。

商用SIEMやSOCプラットフォームの見積もりを取ったことがある方ならご存じの通り、ライセンス費用だけでアナリストの人件費を上回ることも珍しくありません。オープンソースのSOC基盤に魅力があるのはそのためですが、それらのツールを「ひとつの製品」として動かすこと — ここで多くのプロジェクトが頓挫します。

本記事は現場からの報告書です。SaaSもクラウドロックインもなく、すべてが顧客自身のサーバー上のDockerで動作し、ソフトウェア費用の合計はゼロ円です。

特に日本企業においては、NISCの「重要インフラのサイバーセキュリティに係る行動計画」やJ-CSIPの情報共有の枠組みに沿って、ログをオンプレで保持し監査可能な形で運用することが、選択肢ではなく要件となるケースが増えています。


構成の全体像

レイヤー ツール 役割
検知 Wazuh 4.x ログ収集、イベントデコード、パターン検知によるアラート発報
ケース管理 DFIR-IRIS アナリストがトリアージ・対応を行う場
統合 SOC Integrator (FastAPI) すべてを連携させるミドルウェア
脅威インテリジェンス VirusTotal + AbuseIPDB アラートにIOC評判情報を付加
ページング PagerDuty 深夜のオンコール呼び出し
自動化 Shuffle SOAR IOC関連のワークフロー自動化
ログ源 FortiGate, Windows AD, VMware ESXi, Sysmon 実際に監視対象としているシステム

各コンポーネントの連携を図示すると以下のとおり:

flowchart TD
    A["FortiGate / Windows AD / VMware / Sysmon"] -->|"syslog / agent"| B["Wazuh Manager"]
    B -->|"デコード済イベント + アラート"| C["Wazuh Indexer"]
    C -->|"5秒毎にポーリング"| D["SOC Integrator (FastAPI)"]
    D -->|"エンリッチ"| E["VirusTotal + AbuseIPDB"]
    D -->|"アラート作成"| F["DFIR-IRIS"]
    D -->|"オンコール呼出"| G["PagerDuty"]
    D -->|"ワークフロー起動"| H["Shuffle SOAR"]
    F -->|"アナリストがトリアージ"| I["KPIダッシュボード"]

オープンソースSOCについてあまり語られない事実 — 個々のツールはどれも優秀である。だが、それらは「ひとつの製品」として動くようには設計されていない。Integration Layerこそが、それを一体感のあるシステムに見せる肝である。


第1部 — Wazuh: 汎用検知から自社環境特化の検知へ

Wazuhは標準で約3,500のルールを備えている。総当たり攻撃、一般的なマルウェア、既知の不審プロセスといった「典型的な脅威」はカバーできる。しかし汎用ルールは汎用な脅威しか捉えない。自社環境において本当に問題となるもの — 特権アカウント、IPレンジ、独自の異常パターン — を検知するには、自分でルールを書く必要がある。

カスタムルールの構成

検知設計書(顧客承認済)のユースケース付録に対応させてルールを分割している:

wazuh_cluster/rules/
  soc-a1-ioc-rules.xml          # DNS / 脅威インテリジェンスヒット
  soc-a2-fortigate-fw-rules.xml # FortiGateファイアウォール
  soc-a3-fortigate-vpn-rules.xml# VPNトンネルイベント
  soc-a4-windows-ad-rules.xml   # Windows認証
  soc-b1-vmware-rules.xml       # vCenter / ESXi
  soc-b2-logmon-rules.xml       # ログ欠損監視
  soc-c1-c3-rules.xml           # 多段階の相関検知
  soc-ioc-cdb-rules.xml         # 脅威インテリジェンスCDBルックアップ

すべてのルールは2系統で実装されている: シミュレーション系 (UAT環境で安全に発火させる用、ID帯は 100xxx) と 本番系 (実トラフィックに合わせて調整、ID帯は 110xxx)。両者を同一ファイルに置くことで、1回のテストセッションで両系統を検証できる。

<!-- シミュレーション系: シミュレーターのJSONイベントで発火 -->
<rule id="100341" level="10">
  <if_sid>110350</if_sid>
  <description>A4-01 [SIM] Windows: 特権アカウント認証失敗</description>
  <group>soc_sim,a4,windows,auth,</group>
</rule>

<!-- 本番系: 実Windowsイベントログのフィールドにマッチ -->
<rule id="110341" level="10">
  <if_group>windows</if_group>
  <field name="win.eventdata.targetUserName" type="pcre2">(?i)(admin|adm_|svc_|sa_|-adm)</field>
  <field name="win.system.eventID">^4625$</field>
  <description>A4-01 [PROD] 特権アカウント認証失敗</description>
  <group>soc_prod,a4,windows,auth,</group>
  <mitre><id>T1110</id></mitre>
</rule>

見落としがちな細部 — location フィールド

機器がエージェント無しで直接syslogをWazuhに送ると、Wazuhは agent.name = wazuh.manager を設定し、送信元IPを location というフィールドに格納する。これはほとんどドキュメント化されておらず、当方も気付くまでに数時間を要した。

VMware ESXi(syslog経由のみ対応)では location のIPでフィルタしなければならない:

<rule id="110401" level="12">
  <if_group>vmware</if_group>
  <field name="location" type="pcre2">^172\.16\.0\.(107|108|109|110)$</field>
  <match>Login failure</match>
  <description>B1-01 [PROD] vCenterログイン失敗</description>
  <mitre><id>T1110</id></mitre>
</rule>

このフィルタが無いと、"Login failure"という文字列を送信した 任意の 機器で発火してしまう。ESXi 4台にスコープを絞ることで誤検知を完全に排除している。

内蔵ルールに「逆らう」のではなく「継承する」

ESXiのSSHイベントはWazuhの sshd デコーダーが処理する — vmware デコーダーではない。したがって if_group=vmware では捉えられない。正攻法は、既に発火している組み込みルールから継承することである:

<rule id="110404" level="10">
  <if_sid>5715</if_sid>  <!-- 組み込みのSSH成功ルール -->
  <field name="location" type="pcre2">^172\.16\.0\.(107|108|109|110)$</field>
  <description>B1-04 [PROD] ESXi SSH成功 — 認可済か確認</description>
  <mitre><id>T1021.004</id></mitre>
</rule>

ルール5715が既にパース済である。こちらは対象ホストへの絞り込みを加えるだけで済む。

CDBリストによる脅威インテリジェンス

WazuhのCDB(Constant Database)ルックアップは、O(log n) で平文ファイル内の値を照合できる。当方では3つのリスト — 悪性IP、悪性ドメイン、マルウェアハッシュ — を運用しており、Integratorが4時間毎にVirusTotalとAbuseIPDBを照会して、過去30日以内に確認されたIOCのみで更新している。

<rule id="110600" level="13">
  <if_sid>22101</if_sid>
  <list field="data.srcip" lookup="match_key">etc/lists/malicious-ioc/malicious-ip</list>
  <description>FortiGateの送信元IPが脅威インテリジェンスリストに一致</description>
  <mitre><id>T1071</id></mitre>
</rule>

数時間を奪われたDockerの罠

Wazuhの公式Dockerイメージはルールを名前付きボリューム(/var/ossec/etc にマウント)で保持している。docker cpsed -i でルールファイルを更新しようとすると、サイレントに失敗するか「Device or resource busy」になる。名前付きボリュームのinodeが優先されるためである。

コンテナを再起動せずにin-placeで確実にルールを更新する唯一の方法は、コンテナ内部からPythonの open().write() 経由で書き込むことである:

docker exec wazuh.manager python3 -c "
with open('/var/ossec/etc/rules/soc-a4-windows-ad-rules.xml', 'w') as f:
    f.write('''<group name=\"soc_mvp,...\">...</group>''')
"
docker exec wazuh.manager /var/ossec/bin/wazuh-control reload

本番のWazuhルールに触れるCI/CDパイプラインは、この点を必ず把握しておく必要がある。


第2部 — Integrator: 「製品らしさ」を生むレイヤー

これがWazuh、IRIS、その他のツールを「4つの別々のシステム」ではなく「ひとつの統合された仕組み」に見せる中核である。FastAPIで実装されており、5つの役割を持つ。

1. アラート同期

5秒毎にIntegratorはWazuh Indexerをクエリし、本番系ルールID(rule.id:[110301 TO 110602])に該当する新規イベントを取得して正規化し、IRISにアラートを作成する。コンポジットキーで重複排除を行い、同一インシデントがキューを溢れさせないようにしている。

2. IOCエンリッチメント

脅威インテリジェンス系のルールでは、IRISアラート作成 にIntegratorがVirusTotalとAbuseIPDBを呼び出す。アナリストはアラートを開いた瞬間に — 信頼度スコア、脅威カテゴリ、最終確認日 — を確認できる。タブ切替もコピペも不要である。

3. 多段階相関検知

複数のイベントを同時に見て初めて見える攻撃も存在する。Wazuhルールはステートレスであるため、PostgreSQL上で相関エンジンを動かしている。代表例は「不可能な移動(impossible travel)」 — 同一ユーザーが、その経過時間では物理的に到達不可能な距離の2都市からVPNログインする現象である:

async def _detect_impossible_travel(self, event: dict) -> dict | None:
    # ... 当該ユーザーの直前ログインを参照 ...
    distance_km = haversine(loc1, loc2)
    min_hours = distance_km / 900  # 商用機の最大速度
    actual_hours = (ts2 - ts1).total_seconds() / 3600
    if actual_hours < min_hours:
        return {"confirmed": True, "distance_km": distance_km, ...}

東京10:00とフランクフルト11:30 — これは物理的に不可能である。IntegratorはC1検知を確定し、両方のログインイベントを添付した高重要度のIRISアラートを作成する。

4. グループ重複排除

1回のパスワードスプレー攻撃で、Windowsのイベント4625が1時間に数千件発生し得る。それらが全部IRISアラートになればキューは使い物にならない。そこで (rule_id, user, host) でグルーピングし、ルール毎にクールダウンを設定している:

_GROUP_DEDUP_RULES: dict[str, int] = {
    "110341": 2,   # 特権アカウント認証失敗 — 2時間
    "110342": 2,   # サービスアカウント認証失敗 — 2時間
    "110344": 4,   # 公開IPからの認証失敗 — 4時間
    "110347": 4,   # runas / 権限なりすまし — 4時間
    "110359": 1,   # パスワードスプレー — 1時間
}

(rule_id, user, host) の最初のイベントが1件のIRISアラートを生成する。クールダウン内の後続イベントは last_seen を更新し incident_events に詳細ログとして記録するが、アラート重複は発生しない。重要なのは、抑制ロジックが明示的かつ監査可能である点である — どのイベントが、どの根拠で抑制されたかを後から検証できる。これは経済産業省ガイドラインや個人情報保護法対応の監査対応上も重要である。

5. SLAベースのKPIトラッキング

各アラートは重要度に応じたSLAタイマーを保持する:

重要度 SLA目標
Critical 1時間
High 4時間
Medium 8時間
Low 24時間

IRISダッシュボードはアラート毎にライブの進捗バーを表示する。緑はSLA内、黄は75%超過、赤はSLA違反である。経営層は実態に即した数値を、アナリストは本当に急ぐべき案件を、それぞれ正確に把握できる。

各アラートには、起動時にYAMLから読み込まれるトリアージガイドラインも構造化された形で添付される:

guidelines:
  110346:
    use_case: "A4-06 — 公開IPからの認証成功"
    steps:
      - "ユーザーと送信元IPを直ちに特定"
      - "リモートワーク・出張等の正当性を確認"
      - "セッション内の特権操作の有無を確認"
      - "未承認の場合: 強制ログアウト、認証情報リセット、IPブロック"

アラートを開けばプレイブックが目の前にある。Wikiランブックへの切替は不要である。

なぜWazuh標準のActive Responseではなく独立サービスにしたのか

Wazuhには「Active Response」フレームワークがあり、ルール一致時にスクリプトを実行できる。当方では採用しなかった。理由は4点:

  1. 状態管理 — 相関検知には履歴が必要である。Active Responseはステートレスである
  2. 非同期I/O — VirusTotalとAbuseIPDBの並列呼出はasync Pythonでは自明だが、シェルでは厄介である
  3. テスト容易性 — Integratorは正規のREST APIを持つ。任意のイベントを POST /monitor/wazuh/ingest で再生し、すべての判断ロジックを検証できる
  4. 疎結合 — IRIS、PagerDuty、Shuffleはすべて差し替え可能である。Wazuhはこれらの存在を一切知らない

27秒問題の正体

本番投入直後、IRIS KPIダッシュボードのロード時間が 27秒 に達する事態が発生した。エンドポイントを直接叩くと高速(128ms)だが、プロキシチェーン経由だと遅い。

原因は、すべてのDB呼出が psycopg.connect() — 同期ブロッキング呼出(TCP接続+認証含めて約34ms)を使っていたことである。Auto-syncは5秒毎に約437イベントを処理し、各イベントで2回のDB呼出を行う:

437イベント × 2DB呼出 × 34ms = 約30秒のイベントループ枯渇

その間に到着したHTTPリクエストはすべてsync処理の後ろで待機させられていた。修正は、毎回の接続生成をやめて永続コネクションプールに切り替えることである:

# 変更前: 毎回新規TCP接続を確立
@contextmanager
def get_conn():
    with psycopg.connect(db_dsn(), ...) as conn:
        yield conn

# 変更後: プールから借用 — 1呼出あたり約1ms
_pool: ConnectionPool | None = None

def init_pool() -> None:
    global _pool
    _pool = ConnectionPool(db_dsn(), min_size=2, max_size=10, ...)

@contextmanager
def get_conn():
    with _pool.connection() as conn:
        yield conn

ダッシュボードのロード時間は 27秒 → 0.2秒 に。教訓: リクエスト毎にDBアクセスを行うFastAPIサービスでは、初日から非同期プールを採用すべきである。

フィードバックループの完成

Integratorが多段階検知(impossible travelなど)を確定すると、Wazuhに対し構造化されたsyslogメッセージを送り返す。Wazuh側にはこのメッセージにマッチするルールがあり、独自のダッシュボードでlevel-15アラートとして発火する。これによりWazuh側で作業しているアナリストもIRIS側のアナリストも、同一のイベントを見ることになる。どちらか一方が「正」になることはない。


第3部 — DFIR-IRIS: フォークせずにカスタマイズする

DFIR-IRISは標準でも極めて完成度が高い — アラート、ケース、IOC、タイムライン、レポート。当方では2方向に拡張しているが、上流コードに触れるのは1行のみである。

Flask Blueprintとして実装したKPIダッシュボード

IRISはFlaskアプリケーションのため、ページ追加は単純である — Blueprintを書き、一度登録するだけで完了する。

@kpi_dashboard_blueprint.route('/kpi-dashboard')
@ac_requires(no_cid_required=True)
def kpi_dashboard(caseid, url_redir):
    return render_template('kpi_dashboard.html', csrf_token=generate_csrf())

@kpi_dashboard_blueprint.route('/kpi-dashboard/api/alerts')
@ac_api_requires(Permissions.alerts_read)
def proxy_list_alerts():
    content, status, _ = _soc_get('/iris/alerts', request.args)
    return Response(content, status=status, content_type='application/json')

フロントエンドはAlpine.jsを採用 — 軽量でビルドステップ不要、プロキシエンドポイントをポーリングしてSLAタイマー付のライブアラートテーブルとワンクリック割当をレンダリングする。

構造化されたアラートノート

自由記述ではなく、すべてのIRISアラートは固定形式のJSONノートを保持する:

{
  "rule": {
    "id": "110344",
    "description": "公開IPからのWindows認証失敗",
    "mitre": ["T1110"]
  },
  "asset": {
    "hostname": "FPFTPSRV02",
    "ip": "172.16.10.50",
    "os": "Windows"
  },
  "network": {
    "src_ip": "91.202.x.x",
    "dst_ip": "172.16.10.50",
    "protocol": "TCP"
  },
  "guideline": {
    "use_case": "公開IPからの認証失敗",
    "steps": ["送信元IPの特定とジオロケーション", "..."]
  }
}

ノートはUI上では人間可読であり、フィルタやレポート用に機械可読でもある。アナリストは rule.idasset.hostname で検索でき、管理者はMITREテクニックでグルーピングしてトレンドレポートを作成できる。

IRISをフォークせずに改造する

「フォークしてソースを改造してデプロイ」は誘惑だが、半年後に上流が新版をリリースした際、ドリフトしすぎてマージ不能というコストが必ず発生する。

当方の方針:

  • カスタムBlueprintはコンテナにbind-mountされたディレクトリに配置
  • フロントエンドは別途コンパイルし静的バンドルとして注入
  • 上流の __init__.py の1行だけがBlueprint登録のための変更

その1行のみが、IRISの新版リリース時に再適用される変更である。それ以外はすべて加算的な変更である。


第4部 — 運用上の教訓

ディスク使用量は予想を裏切る

logall=yes はデコード済イベントを全件アーカイブする — アラートに該当した分だけではない。フォレンジックには有用だが、ディスク使用量は容赦無く増える。VMwareの定常的な調査作業1回でディスクが86%使用率に達した。現在は既定でオフにし、対象を絞った調査ウィンドウでのみ有効化している:

<global>
  <logall>no</logall>
  <logall_json>no</logall_json>
</global>

個人情報保護法対応の観点では、ログ保持期間は明示的なポリシーで決めるべきであり、ディスク容量の副作用で決まるべきではない。

カバレッジとアラート疲れはトレードオフではなくチューニング項目

うるさいルールを抑制するのは安易な解であり、より良い解は前述のグループ重複排除パターンである — すべてのイベントは記録された上で、クールダウンウィンドウあたり最初の1件だけがアラート化される。カバレッジを失わず、監査証跡も残り、アナリストキューも実用的なまま保たれる。

SOC自身を監視する

Integratorは自身の依存先 — Wazuh Manager、Wazuh Indexer、IRIS、Shuffle、PagerDuty — を2分毎にpingしている。何かが落ちていれば system-health カテゴリでIRISアラートを作成する。アナリストはセキュリティアラートと同じキューでインフラ障害を確認できる。第二の監視ツールは不要である。

結局メールが一番堅い通知手段である

クリティカルなIRISアラートが作成されると、SOCチームと予備のメールボックスに、アラートタイトル、重要度、対象資産、IRISへの直リンクを含むメールが送信される。ダッシュボードは落ちる。受信箱は落ちない。


数値

指標
カスタムWazuhルール 86件 / 8ファイル
本番で発火しているルール 64件中17件
処理済IRISアラート数 91,000件以上
Auto-syncサイクル 5秒毎
IOCリスト更新 4時間毎
KPIダッシュボードロード時間(改修前) 27秒
KPIダッシュボードロード時間(改修後) 0.2秒
DB呼出オーバーヘッド(改修前 / 後) 34ms / 1ms未満

やり直すなら変えること

初日から非同期DB: FastAPIアプリで同期版 psycopg.connect() を使うのは潜在的なパフォーマンス地雷である。最初のリポジトリ層を書く前に psycopg_pool.AsyncConnectionPool を選択すべきである。

WazuhルールテストをCIに組み込む: rule_test APIは生のログ行を受け取り、発火するルールを返す。これをpytestフィクスチャでラップすればルール衝突を本番投入前に検出できる。

シミュレーションと本番のログを別ポートで受ける: 当方ではsyslog 514を両用したため、soc_sim/soc_prod の二系統運用が必要になった。最初から別ポートにしておけば構成は単純になる。


まとめ

オープンソースだけで構成された本番SOCは、可能であるばかりか、優れたエンジニアリングでもある。ただしオープンソースのパーツだけでは「製品」にはならない。Integration Layer — マーケティングされない部分 — こそが、実際のSOCが宿る場所である。

商用SIEMのライセンス費用に頭を抱えているなら、このスタックは現実的な代替手段である。難しいのはツール選定ではない。難しいのは「接合部」である。

日本企業にとっては、NISCの重要インフラ行動計画やJ-CSIPの情報共有要件、そして個人情報保護法とのコンプライアンス上、ログを自社データセンターに留め、監査可能な抑制ロジックを持ち、海外クラウドへの機微情報送信を回避できる本構成は、追加の価値も提供する。


Simplico では、オープンソースを基盤とした本番運用のセキュリティシステムを構築しています。SOC設計、検知体制の刷新、SIEMライセンスからの脱却をご検討の際は、お問い合わせください。


Get in Touch with us

Chat with Us on LINE

iiitum1984

Speak to Us or Whatsapp

(+66) 83001 0222

Related Posts

Our Products