Skip to content

Security: junichikatsu/SocraMetry

Security

docs/security.md

セキュリティ方針

項目 内容
ドキュメント版数 v0.1
作成日 2026-08-09
対応 F05(キー秘匿・入力バリデーション)/ F04(連打防止)
関連 scope-v0.1.md / requirements.md §6.2

1. この製品固有のリスク

一般的な Web アプリのリスクに加えて、本製品には性質上避けられない 2 つのリスクがある。

# リスク なぜ起きるか
R1 入力に認証情報が混入する ユーザーが貼り付けるのはエラーログである。ログには API キー・トークン・接続文字列が日常的に含まれる
R2 入力が外部の LLM プロバイダに送られる 診断のためにエラーテキストを LLM に渡す必要がある

R1 は「ユーザーが気をつける」で解決しない。 本人が気づかずに貼り付けるから混入するのであり、システム側で除去する以外に方法がない。


2. 3 つの基本方針

2.1 API キーをフロントエンドに出さない(F05)

  ✗ してはいけない       ブラウザ ──[APIキー]──▶ OrcaRouter
                              ↑ DevTools で誰でも見える

  ✓ 本製品               ブラウザ ──▶ enebular 関数 ──[APIキー]──▶ OrcaRouter
                                          ↑ envVars にのみ存在
項目 実装
保管場所 enebular クラウド実行環境の envVars のみ
リポジトリ .env.gitignore で除外。.env.exampleプレースホルダのみ
CI GitHub Secrets に OrcaRouter のキーを置かない。 envVars はコンソールで手動管理する(deployment.md §3.2
フロント app.js/v1/... を叩くだけ。キーに関する記述が一切ない

CI にキーを置かない構成にしたのは、漏洩経路そのものを減らすため。 CI が知る必要のあるシークレットは enebular のアクセスキーだけになる。

2.2 入力バリデーション(F05)

サーバ側で必ず検証する。 フロントの検証は UX のためのものであり、防御ではない。

対象 制約 超過時
errorText 1〜20,000 文字 400 INVALID_INPUT
codeSnippet 0〜10,000 文字 400 INVALID_INPUT
recentChange 0〜1,000 文字 400 INVALID_INPUT
language / framework 事前定義リストの値のみ(自由文字列を受け付けない) 400 INVALID_INPUT
selectedOptionId 該当設問に存在する選択肢 ID のみ 400 INVALID_INPUT
sessionId ULID 形式 + 本人所有であること 404 SESSION_NOT_FOUND

実装は packages/shared/src/schemas/ の Zod スキーマ。 FE と BE が同じスキーマを参照するため、検証内容がずれない。

文字数制限は防御でもある

20,000 文字の上限は UX のためだけではない。 LLM への入力トークン数の上限であり、そのままコストの上限になる。 入力バリデーションとコスト管理がここで一致している。

他人のセッションを触れないこと

すべてのセッション操作で、データストアのメインキーに ownerId を含める

// 他人の sessionId を渡されても、キーが一致しないので 0 件しか返らない
getItem(sessions, { ownerId: ownerIdOf(authUser), sessionId: req.params.id })

パラメータの sessionId だけでは引けない構造にすることで、 アクセス制御の書き忘れが「情報漏洩」ではなく「見つからない」に着地する

2.3 ログに PII と秘匿情報を残さない

記録するもの / しないもの

記録する 記録しない
sessionId(ULID。個人を特定しない) エラーテキストの本文
ownerId(内部 ID) コード断片
役割 / モデル名 / トークン数 / レイテンシ メールアドレス・氏名
推定コスト パスワード・トークン(当然)
エラーコード 例外のスタックトレースに含まれる入力値

ログの目的は運用とコスト把握であり、入力内容の再現ではない。 デバッグに本文が必要な場面はあるが、それは開発環境(MOCK_MODE または LOG_LEVEL=DEBUG)に限定し、本番では出さない。

例外処理での漏洩に注意する

// ✗ 入力がそのままログに乗る
catch (e) { console.error('failed', { input: req.body, error: e }) }

// ✓ 入力の「形」だけを残す
catch (e) { console.error('failed', { errorTextLength: body.errorText.length, code: e.code }) }

例外を握りつぶさず、かつ入力を残さない。 エラーメッセージに入力値を埋め込まないのが原則。


3. 秘匿情報のマスキング(R1 への対策)

エラーログには認証情報が混入する前提で扱う。 データストアに保存する前・LLM に送る前にマスクする。

マスキングはクライアントとサーバの二段で行う。

  ┌─ ブラウザ ──────────────────────────────────┐
  │  ユーザー入力                                 │
  │      │                                       │
  │      ▼                                       │
  │  ┌─────────────────────┐                     │
  │  │  マスキング(決定的) │  貼り付けた瞬間に実行 │
  │  └──────┬──────────────┘                     │
  │         ▼                                     │
  │  ┌─────────────────────┐                     │
  │  │  プレビュー表示       │  利用者が追加で伏せる │
  │  └──────┬──────────────┘                     │
  └─────────┼───────────────────────────────────┘
            │  マスク済みテキストのみ送信
            ▼
  ┌─ サーバ ────────────────────────────────────┐
  │  ┌─────────────────────┐                     │
  │  │  マスキング(再実行) │  ← ここが正。冪等     │
  │  └──────┬──────────────┘                     │
  │         ├──▶ データストアへ保存               │
  │         └──▶ LLM へ送信                       │
  └─────────────────────────────────────────────┘

同じ純関数(packages/core/src/masking.ts)を両側で使う。 外部依存も Node API も持たない文字列処理なので、esbuild でフロントにもバンドルできる (ADR-012)。生テキストはブラウザから出ない。

クライアント側のマスクは UX のためであり、保証ではない。 正はサーバ側の再実行である。クライアントは信用せず、冪等なので二重適用しても無害。 「クライアントで消しているから安全」という説明はしない。

手動マスク(利用者が選んで伏せる)は、原理的にクライアントでしか実装できない。 どの範囲を伏せたいかはサーバには分からないため。この制約から二段構成が決まっている。

表示は 2 段階に分かれる。 難易度が違うため、実装上も分けて扱う。

内容 工数 v0.1 状態
A 貼り付けた瞬間に、マスク後のテキストを入力欄の下のパネルに出す 30〜60 分 必須 実装済み(#27)
B 利用者が選択範囲を指定して追加で伏せる 60 分 実装予定(Day 4 に最終判断) 未実装

A の実装のためにフロントエンドへバンドラを入れたADR-013 の改訂)。 「同じ純関数を両側で使う」を守るには、packages/core/src/masking.ts を ブラウザへ届ける手段が要る。正規表現を書き写す案は採っていない。 表示と実体がずれたマスキングは、無いより悪い。

除外語(MASK_WORDS)はサーバにしか無く、クライアントには渡していない。 どの社名を扱っているかがブラウザから読めてしまうため。 その分、プレビューで消えない固有名詞は利用者が手で書き換える導線に寄せている。

A は確認画面(モーダル)ではなく常時表示のパネルとする。 追加のクリックが 1 回でも増えると、デモの尺と NFR-P1 に影響するため。 A はマスキング関数を呼んで表示するだけなので実質ゼロリスク。 B は選択範囲の取得と置換が入るため、Day 4 が詰まった場合の削り対象になり得る。

ブースの想定問答は A だけで成立する文面にしておく(「伏せた内容を送信前に画面に出しているので、 送る前にご自身で確認できます」)。B が入った時点で「さらに、自分で追加で伏せることもできます」を足す。

検出ルール

対象 検出方法 置換後
API キー類 sk-, ghp_, gho_, AKIA, AIza 等のプレフィックス + 長さ [REDACTED_KEY] v0.1
Bearer トークン Authorization: Bearer ... [REDACTED_TOKEN] v0.1
接続文字列 postgres://user:pass@... の資格情報部 [REDACTED_CREDENTIALS] v0.1
JWT eyJ で始まる 3 セグメント [REDACTED_JWT] v0.1
メールアドレス 正規表現 [REDACTED_EMAIL] v0.1
ローカル絶対パス /Users/... / /home/... / C:\Users\... で始まるパスの、ファイル名より前をすべて <path>/<file> v0.1
除外語リスト 環境変数 MASK_WORDS に登録した社名・製品名の完全一致 [REDACTED_NAME] v0.1
組織別マスキング辞書 管理者が登録した顧客名・製品名・コードネームの完全一致(FR-41 [REDACTED_NAME] v0.2

絶対パスはユーザー名部だけでなく、プレフィックス全体を伏せる。

  Before:  at /Users/tanaka/projects/acme-corp/src/invoice.js:42
  After:   at <path>/invoice.js:42

ユーザー名部だけを置換する方式では、中間ディレクトリに残る顧客名・プロジェクト名が そのまま LLM へ送られる(例: acme-corp)。クライアント別のディレクトリ構成や モノレポでは珍しくなく、スタックトレースは本製品のコア入力であるため混入経路として最も確実になる。

強く伏せても診断精度は落ちない。 診断に効くのはエラーメッセージ・ファイル名・行番号であり、 ディレクトリ階層ではない。実装原則 #4(過剰にマスクしてよい)にそのまま沿う判断である。

マスキングを通す入力(FR-11 の対象)

「利用者が入力するテキスト」はすべて対象とする。 画面ごとの例外を作らない。

入力 送信先 対象 プレビュー表示
エラーテキスト Diagnoser / Questioner
コード断片 同上
直前の変更 同上
原因宣言(FR-09) Judge
振り返りの回答 Reporter

判断基準は「その入力が LLM に届くか」。 原因宣言と振り返りは自由記述であり、 マスキングを通さないと素通りする。

プレビューを出すのはエラー投稿画面のみ。 原因宣言は利用者が自分の言葉で書くため 混入リスクが構造的に低く、体験の山場にパネルを足すと煩雑になる。 マスキング自体はすべての入力に適用する。

自動では消せないもの(明示する)

対象 v0.1 v0.2
形式が決まった秘匿情報 ✅ 正規表現で確実に検出 同左
自社の社名・製品名 ✅ 除外語リスト(環境変数) ✅ 組織辞書(FR-41)
顧客名などの固有名詞 自動では検出できない。 プレビューで利用者が伏せる △ 組織辞書に登録されていれば消える

正規表現では固有名詞を検出できない。 「Acme 社の決済 API」を機械的に特定する手段がないため。 ここを「自動で消せる」と書くと、反例を 1 つ出された時点で説明が崩れる。 できないことをできると書かない。

なお §2.5 / UC-10 の匿名化(実務セッション → 問題集への登録, FR-35)は 保存後の二次処理であり LLM を使えるため、本節とは別の手段が取れる。 こちらは v0.2 で、登録は draft から始め人の目視確認を必須とする(Q-13)。

除外語リストをクライアントに配ると、その語リストが閲覧可能になる。 v0.1 は招待制の限定公開のため許容するが、FR-41 の組織辞書は認証後にのみ取得する。

実装上の原則

# 原則 理由
1 LLM を使わない。 正規表現による決定的な処理 LLM に送るに処理する必要があるため、そもそも LLM は使えない
2 サーバ側は入口で 1 回だけ通す。 保存直前・送信直前に散らさない 通し忘れる箇所が生まれる。ハンドラの最初で masked な型に変換し、以降は生の入力を持ち回らない
3 単体テストを書く(F13) 正規表現は壊れやすい。代表パターンの回帰テストを持つ
4 過剰にマスクしてよい 誤ってマスクした場合の損害(診断精度がわずかに落ちる)より、漏らした場合の損害の方が大きい

実装は packages/core/src/masking.ts(純関数)。フロントとサーバの両方が同じものを import する。


4. LLM プロバイダへのデータの扱い(R2 への対策)

項目 方針
学習への利用 OrcaRouter 側で学習に使わせない設定を確認し、明記する(NFR-S4)
送信内容 マスキング済みのエラーテキスト・コード断片のみ。メールアドレスや氏名は送らない
利用者への説明 入力が外部の LLM に送られること、マスキングを行っていること、および固有名詞は自動では消えないことを UI に明記する。何が伏せられたかは送信前にプレビューで見せる

「マスキングしているから安全」とは書かない。 正規表現で検出できない形式の秘匿情報は残りうる。 利用者が判断できるよう、何をしていて、何を保証していないかを正直に書く。


5. 認証(v0.1 の簡易実装)

項目 実装
方式 メール + パスワード
ハッシュ node:cryptoscrypt(ソルトはユーザーごとに生成)
セッション JWT (HS256) を HttpOnly / Secure / SameSite=Lax の Cookie に格納
有効期限 24 時間
登録 招待コード必須(環境変数 INVITE_CODE
パスワード 8 文字以上。使い回し検知などは v0.2

招待コードを必須にする理由

公開 URL に無制限のサインアップを置かない。理由は 2 つある。

# 理由
1 LLM コストの流出防止。 誰でも登録できると、1 セッションあたりのコストが無制限に発生しうる
2 v0.1 は限定公開のため。 不特定多数に開く段階ではない

パスワードを平文で保存しない、Cookie を HttpOnly にする、といった基本は当然として、 この製品でいちばん守るべき資産は「ユーザーが貼り付けた業務コード」である。 認証の強度より、§2・§3 の入力の扱いの方が本質的に重要。


6. レート制限と連打防止(F04)

実装 目的
フロント 送信中はボタンを disabled にする 誤操作による二重送信
サーバ 冪等性(同じ questionId への二重回答は同じ結果を返す) ネットワーク再送
サーバ セッション作成 10 回 / 時 / ユーザー 意図的な連打・コスト流出
サーバ 1 セッション 80,000 トークン上限 1 セッションでの暴走

冪等性がなぜセキュリティ項目なのか

連打防止をフロントだけで実装すると、ボタンを押さずに直接 API を叩けば無効化できる。 サーバ側の冪等性がなければ、1 回の操作で LLM を何度でも呼ばせられる。

フロントの制御は UX、サーバの冪等性が防御。 両方を実装する。

カウンタはデータストアの既存アイテム内に持ち、 レート制限のためだけにアクセス枠を消費しないturns.length などで判定できる)。


7. その他

項目 方針
HTTPS enebular の HTTP トリガーが TLS を終端する
CORS v0.1 では同一オリジンのため不要ADR-012)。v0.2 で分離する場合は ALLOWED_ORIGIN の完全一致
XSS LLM の出力を innerHTML で描画しない。textContent を使う。選択肢のラベルも同様
依存パッケージ 最小限に保つ(openai / @uhuru/enebular-sdk / hono / zod)。ZIP にバンドルされるため、依存を増やすことが攻撃面を増やす
データ削除 ユーザーが自分のセッションを削除できる(NFR-S7)

LLM の出力を DOM に入れるときの注意

LLM の出力は信頼できない入力として扱う。 ユーザーが貼り付けたエラーテキストに <script> が含まれていれば、 LLM がそれを引用した文を生成しうる。

// ✗ LLM 出力をそのまま HTML として挿入
optionEl.innerHTML = option.label

// ✓ テキストとして挿入
optionEl.textContent = option.label

フレームワークを使わない構成(ADR-013)では、 React のような自動エスケープがない。ここは意識して守る必要がある。


8. v0.1 で対応しないもの

正直に列挙する。やっていないことを「やった」と書かない。

項目 v0.1 理由 / 予定
マルチテナント・ロール管理 F16 Won't。v0.2
監査ログ(誰が誰のデータを見たか) 権限管理が前提。v0.2
SSO / OIDC 簡易ログインで代替。v0.2
パスワードリセット 招待コード制の限定公開のため。v0.2
CSRF トークン SameSite=Lax の Cookie + POST のみで実質防げる範囲に留める。v0.2 で明示的に対応
依存パッケージの脆弱性スキャン npm audit を CI に入れる(余力があれば)
WAF / DDoS 対策 実行環境側に委ねる

There aren't any published security advisories