# セキュリティ検証 — AI Secure v0.2

実施日: 2026年9月12日 / 対象: v0.2.0（コミット `047c152`）と、この検証で入れた修正 / 検証環境: macOS 25.6.0、Python 3.14.6

「本当にセキュアなのか」を確かめるために、コードを読むだけでなく、**実際に攻撃的な入力を与えて挙動を確認**しました。この文書は、確かめたこと・見つかった問題・直したこと・**確かめていないこと**を分けて記録します。

検証は2段階で行いました。(1) 実装者による攻撃入力の実測、(2) 差分を渡した**独立したレビュー担当による再検査**。(2) は (1) が見落とした欠陥を3件見つけ、さらに (1) の修正が新たに作り込んだ欠陥を1件指摘しました。

結論として、**v0.2で追加した取り込み機能に、検知を回避できる欠陥が合計4件ありました。**いずれも実際に再現し、修正し、回帰テストで固定しています。本番のセキュリティ製品として十分かと言えば、`SECURITY.md` の本番化ゲートが未達である点は変わりません。

## 1. 何を守るものとして検証したか

`SECURITY.md` の脅威モデルに従いました。

| | 扱い |
|---|---|
| 信頼する | OSユーザー、ブラウザ、ローカルディスク、実行中のPythonプロセス |
| **信頼しない** | **取り込むログの中身**（攻撃者が1レコードを書き込める前提）、手動入力のJSON、ローカルLLMの出力、ブラウザ上の他サイト |
| 対象外 | OS管理者権限を持つ攻撃者、ホストマルウェア、ブラウザ拡張 |

v0.2で新しく増えた攻撃面は **「外部ファイルを読む」** ことです。ここを重点的に見ました。

## 2. 見つかった問題と対応

### 問題1: ログ1行で検知を止められる（重要度: 中 / 修正済み）

**内容.** 取り込み境界は「読めない行は読み飛ばして数える」と文書化していましたが、実際には次の3つが読み飛ばされずスナップショットに入り、**その後の検証で全体が拒否**されていました。

- ユーザー名・セッションID・ファイルパスに制御文字（NUL、タブ、改行）が含まれる行
- 同じイベントIDで内容が異なる行

**実害.** ログに1レコード書き込める立場の攻撃者が、**取り込み全体を失敗させて分析を止められます。** 例えばファイル名にタブを含むファイルを1つ作る、制御文字を含むユーザー名でログインを試みる、といった操作で成立します。分析が止まれば、同じログに含まれる本当の侵害の痕跡も検知されません。検知回避として機能します。

**確認した実際の挙動（修正前）:**

```text
制御文字を含むユーザー名（2行中1行）→ 正規化で全体が失敗
同じイベントIDで内容が違う行        → 正規化で全体が失敗
ファイルパスにタブ                  → 正規化で全体が失敗
```

**修正.** 取り込み境界で、後段の検証と同じ条件を先に適用するようにしました。制御文字を含む値は行単位で読み飛ばして理由別に計上し、同じイベントIDで内容が異なる行も読み飛ばして計上し、警告に出します（同一内容の再送は従来どおり重複として除外）。**1行の問題が全体を壊さない**ことをテストで固定しました。

```text
制御文字を含むユーザー名 → 1行読み飛ばし「actorに制御文字が含まれます」、残りは正常に分析
同じイベントIDで内容が違う → 1行読み飛ばし「同じイベントIDで内容が異なります」＋警告
```

### 問題2: 画面が毎回スナップショット全体を受け取る（重要度: 中 / 修正済み）

**内容.** v0.2でCLIの取り込み上限を200万イベントまで広げた一方、`/api/state` はスナップショット全件をそのまま返していました。10万イベントで **27.3 MiB**、上限付近では数百MiBになります。画面は実際には件数しか使っていません。

**実害.** 正規の利用で画面が固まり、初動対応の道具として使えなくなります（可用性）。外部から引き起こせる問題ではないため重要度は中です。

**修正.** `/api/state` は要約（資産台帳、取り込み元、種別ごとの件数）のみを返し、イベント全件は明示的な書き出し `/api/export` でのみ返すようにしました。書き出しにも上限（20万件、超過時はCLIを案内）を設けています。

```text
10万イベント時の常時ペイロード: 27.30 MiB → 0.18 MiB
```

### 問題3: 未来日付のシナリオが生成できてしまう（重要度: 低 / 修正済み）

`baseline --days 20` などで終了時刻が未来になるシナリオが作られ、`analyze` や `import --ingest` が「スナップショット時刻が未来すぎます」で拒否していました（この拒否自体は正しい検証です）。`--start` で開始日を指定できるようにし、未来になる場合は生成時に警告するようにしました。

### 問題4: 書き出したスナップショットに仮名化前の値が残る（重要度: 低 / 文書化と警告を追加）

`import --out snapshot.json` が出力する中間ファイルには、**仮名化前のユーザー名・セッションID・ファイルパスがそのまま入ります。** 仮名化は保存時に行われる設計のため意図どおりで、`docs/CONNECTORS.md` にも記載がありますが、「製品が出力した安全なファイル」と誤解されやすい形でした。書き出し時に警告を出すようにしています。

実測では、**データベースと品質レポートには生の識別子は残っていません**（下記4章）。

### 問題5: 機器名を選ぶだけで、他人の資産に成りすませた（重要度: 高 / 修正済み）

**内容.** 識別子の正規化 `slug()` は、規則に合う名前をそのまま通し、合わない名前にはハッシュを付けて `名前-ハッシュ` を返していました。この**出力形式そのものが規則に合う**ため、再度通すと素通りします。つまり生成された識別子と生の名前が同じ名前空間にあり、攻撃者は他人の機器の正規化後の名前を**オフラインで計算して、自分の機器にその名前を付ける**だけで衝突させられました。

コードと `docs/CONNECTORS.md` は「別々の名前が同じIDに統合されることはありません」と明記していましたが、成立していませんでした。

**実害.** 取り込み時、同じIDの資産は「観測時刻が新しい方」で丸ごと置き換えられます。攻撃者が1時間後に観測された無害な端末を紛れ込ませると、**本物のVPN機器の「外部公開」「未適用」「脆弱性参照」が消えます。** その結果 AS-001 が鳴らず、機器が `exposed_pending` に入らないため、**その後どれだけ機密ファイルを大量参照されても最優先の相関 AS-004 は発火しません。** 痕跡は品質レポートの `normalized_identifiers: 1` のみでした。

```text
入力: 「VPN装置 01」(公開・未適用) と 「VPN---01-c4833859」(端末・適用済) の2台
出力: 1台。公開=False、パッチ=applied、脆弱性=なし  ← 本物のVPNが消えた
```

**修正.** 生成した識別子と生の名前の名前空間を分離しました。`:` は生の名前としては保持されない文字にし、生成した識別子の区切りに使います。`:` を含む値は必ず変換側に回るため、**生成形式は生の名前からは決して作れません。** ハッシュ長も32ビットから64ビットに延ばしました。加えて、1回の取り込み内で異なる2つの名前が同じIDになった場合は、統合せずエラーで止めます。

### 問題6: ログ1行を仕込むと、本物の証跡を消せた（重要度: 中 / 修正済み）

**内容.** 問題1の修正で、同じイベントIDで内容が異なる行を「読み飛ばす」ようにしました。ファイルは先頭から読むため、**先に置かれた行が残り、後から来た本物が捨てられます。** 修正前は全体を失敗させていた（安全側に倒れていた）ので、この修正は危険側への転換でした。**自分の修正が作り込んだ欠陥です。**

**実害.** 収集側のイベントIDを観測・予測できる攻撃者が、無害な行を先に1行仕込むだけで、**23:17の本物の特権ログイン（AS-002 の対象であり AS-004 の起点）をスナップショットから消せます。**

**修正.** どちらの行も捨てません。IDが衝突した場合は、後続の行に内容から導出した別IDを付与して両方残し、品質レポートと警告に出します。証跡を失わず、かつ全体も失敗しません。

### 問題7: 取り込み元の申告を、検証済みとして表示・記録していた（重要度: 中 / 修正済み）

**内容.** スナップショットの `provenance`（取り込み元のファイル名・SHA-256・行数）は、`aisecure import` が実際にファイルを読んで計算するものです。しかし **手書きのJSONに書くだけでも同じように受理**され、画面には取り込み元として表示され、鍵付き監査チェーンにも記録されていました。手動入力のJSONは脅威モデル上「信頼しない」入力です。

**実害.** 細工したスナップショットを渡された担当者の画面に「`corp-vpn-audit-2026-09.csv` / SHA-256 / 120,000行を読み取り」と表示されます。実際には1行も読んでいません。改ざん検知のある監査記録に、偽の出所が残ります。

**修正.** 出所は**データではなく処理側が主張するもの**にしました。実際にファイルを読んだ実行（`import --ingest`）だけが「検証済み」を付けられ、JSONから読み込んだ申告は `verified: false` になります。画面は `[この実行で読み取り]` と `[申告値 / 未検証]` を区別して表示します。

### 問題9: 深くネストしたJSON 1行で取り込み全体が落ちた（重要度: 低 / 修正済み）

macOS + Python 3.14 では読み飛ばせていた深いネストのJSONが、**Python 3.11 では `RecursionError` になり未処理例外で全体が失敗**していました（json モジュールの挙動がバージョンで異なるため）。CIのマトリクスが検出しました。`ValueError` と `RecursionError` の両方を捕捉するようにしています。

**手元の1環境で試して問題が出ないことは、対応環境で問題が出ないことを意味しません。**

### 問題8: 数値でない1行で取り込み全体が落ちた（重要度: 低 / 修正済み）

`bytes` に `inf` や `1e400` が入った行が1つあると `OverflowError` が送出され、**取り込み全体が未処理例外で失敗**していました（`ValueError` しか捕捉していなかった）。問題1と同じ「1行で全体が落ちる」形です。非有限数を明示的に除外し、その行の値のみ不明として扱うようにしました。

## 3. 攻撃を試して問題がなかったもの

以下は実際に入力を与えて確認し、**問題が出なかった**ものです。

| 試したこと | 結果 |
|---|---|
| 10万段ネストのJSON、400 KBの1行、バイナリ列、巨大数 | 行単位で読み飛ばし、未処理例外なし（**ただしPython 3.11では当初落ちていた。問題9**） |
| CSVの10万文字フィールド、500列、空ファイル | 上限として拒否。エラーに入力内容を含めない |
| `<script>alert(1)</script>` や `<img src=x onerror=...>` を機器名・ユーザー名に | 機器名は識別子化で無害化（`img-src-x-onerror-alert-1-63b586b4`）、ユーザー名は仮名化。画面はすべて `textContent` 生成で `innerHTML` 不使用 |
| 書式文字列 `%s%n{0.__class__}` をユーザー名に | 単なる文字列として扱われる |
| プロファイルに許可外の項目（`file_contents`、`execute`）を追加 | 拒否 |
| ログにだけ `password` / `secret_note` 列を追加 | スナップショットに一切入らない |
| シンボリックリンク、通常ファイル以外、サイズ超過をソース指定 | 拒否 |
| タイムゾーンのない時刻 | 推測せず行を読み飛ばす |
| 別名の機器が同じ識別子に統合されるか | **問題あり（問題5）。修正後は生成形式を生の名前から作れないため統合されない** |
| 認証なし・誤ったトークン・不正なHost/Origin・クロスサイト要求 | それぞれ401/403（v0.1からの継続確認） |
| 承認フローの迂回（確認文字列なし、理由が短い、別スナップショットID、存在しない計画、二重承認、**保存済みの計画本文をDB上で書き換えてから承認**） | すべて拒否。最後の1つは現行ポリシーから計画を再生成して照合するため完全性エラーになる |

### LLM境界（モック応答で実測）

ローカルLLMの応答を敵対的に差し替えて確認しました。**どの異常応答もルール説明へ戻り、生成AIを使ったとは表示されません。**

| モデルが返した応答 | 結果 |
|---|---|
| 入力にない根拠IDを捏造 | ルール説明へフォールバック |
| ツール呼び出し（`revoke_session`）を要求 | ルール説明へフォールバック |
| スキーマ外のキー（`execute`）を追加 | ルール説明へフォールバック |
| 5,000文字の要約 | ルール説明へフォールバック |
| JSONでない自然文（「セッションを失効させます」） | ルール説明へフォールバック |
| 500文字のモデル名を自称 | ルール説明へフォールバック |
| 正常な応答 | 採用。表示は `ollama/llama-local` |

モデルへ実際に送信された内容も確認しました。送信されたのは生成済みの理由文と許可済みの根拠IDのみで、**仮名化済みのユーザー識別子も機器名も含まれていません。** ツール定義は一切渡していません。

なお、これは「モデルが何を返しても対応権限に届かない」という**構成**の確認です。実モデルの出力品質・日本語の妥当性は検証していません。

## 4. 生データがどこに残るかの実測

判別用の文字列（ユーザー名・セッションID・ファイルパス・日本語ファイル名）を含むログを取り込み、生成物を全文検索しました。

| 生成物 | 仮名化前の値 | 権限 |
|---|---|---|
| `state.sqlite3`（保存DB） | **残らない** | `-rw-------` |
| 品質レポート `quality.json` | **残らない** | `-rw-------` |
| 中間スナップショット `snapshot.json` | **残る**（設計どおり・問題4） | `-rw-------` |
| 監査記録 | 残らない（承認理由も鍵付きハッシュのみ） | — |

DBは平文SQLiteのままです（`SECURITY.md` の「できないこと」1）。仮名化は匿名化ではなく、アクセス行動の記録としての機微性は残ります。

## 5. 性能上の限界（実測値）

問題ではありませんが、境界として記録します。

| 項目 | 実測 |
|---|---|
| 10万イベントの取り込み | 1.1秒、DB 25.4 MiB、ピークメモリ 395 MiB |
| 監査記録1,000件時点の1書き込み | 3.9 ms（全件検証を毎回行う設計のため、件数に比例して増加） |

監査チェーンは「壊れたチェーンには書き込まない」ために毎回全件を検証します。安全側の設計ですが、記録が数万件を超える運用では書き込みが目に見えて遅くなります。本番化時は増分検証と外部チェックポイントの併用が必要です。

## 6. この検証で確かめていないこと

**「検証した」と言えないものを、言えないものとして残します。**

- 実際のVPN機器・IdP・ファイルサーバーのログ形式。文字コード（cp932等）の実データ、ローテーション中のファイル、圧縮ログ。
- Python 3.11〜3.14 以外の処理系、Windows・Linux 上での取り込み挙動（CIはUbuntuの3.11〜3.14のみ）。
- 実ブラウザでのE2E試験（画面はNode上の最小DOMでの実行確認のみ）。Safari・Firefox・Windowsは未確認。
- 実モデルを接続した状態でのLLM経路。プロンプトインジェクション耐性は、モデルに実行権限も原文ログも渡していないという構成上の主張であり、実モデルでの試験ではありません。
- 侵害されたホスト、悪意あるOSユーザー、ブラウザ拡張。設計上の対象外です。
- 外部の第三者・専門機関によるレビューおよび侵入テスト。本検証は実装者と、同じ作業の中で差分を渡した独立レビュー担当によるものです。**社外の監査ではありません。**
- 暗号実装の正しさ（HMAC-SHA256の標準ライブラリ利用に依存）。
- 長期運用、大規模ログ、複数管理者、マルチテナント。

## 7. 結論

v0.2の新しい攻撃面（外部ファイルの取り込み）から、**検知を回避できる欠陥が4件**見つかりました。うち1件は最優先の相関検知を完全に無効化できるもの（問題5）、1件は本物の証跡を消せるもの（問題6）、1件は偽の出所を証拠として記録させるもの（問題7）です。すべて再現・修正し、回帰テストで固定しました。

**「攻撃入力を試して問題が出なかった」だけでは不十分だった**ことが、この検証自体の教訓です。実装者の検証は、自分が想定した攻撃しか試しません。問題5・6・7は、差分を独立して読み直した再検査で初めて出ました。

ただし、これは **「プロトタイプとして主張している境界が成立している」** ことの確認です。`SECURITY.md` に挙げた本番化の条件——保管時暗号化、独立した監査保管、SSO・権限分離、実ログでの誤検知測定、第三者によるレビューと侵入テスト——は引き続き未達です。この製品を実環境の防御に使ってはいけません。
