| 項目 | 内容 |
|---|---|
| ドキュメント版数 | v0.1 |
| 作成日 | 2026-08-09 |
| 対応 | F05(キー秘匿・入力バリデーション)/ F04(連打防止) |
| 関連 | scope-v0.1.md / requirements.md §6.2 |
一般的な Web アプリのリスクに加えて、本製品には性質上避けられない 2 つのリスクがある。
| # | リスク | なぜ起きるか |
|---|---|---|
| R1 | 入力に認証情報が混入する | ユーザーが貼り付けるのはエラーログである。ログには API キー・トークン・接続文字列が日常的に含まれる |
| R2 | 入力が外部の LLM プロバイダに送られる | 診断のためにエラーテキストを LLM に渡す必要がある |
R1 は「ユーザーが気をつける」で解決しない。 本人が気づかずに貼り付けるから混入するのであり、システム側で除去する以外に方法がない。
✗ してはいけない ブラウザ ──[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 のアクセスキーだけになる。
サーバ側で必ず検証する。 フロントの検証は 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 だけでは引けない構造にすることで、
アクセス制御の書き忘れが「情報漏洩」ではなく「見つからない」に着地する。
| 記録する | 記録しない |
|---|---|
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 }) }例外を握りつぶさず、かつ入力を残さない。 エラーメッセージに入力値を埋め込まないのが原則。
エラーログには認証情報が混入する前提で扱う。 データストアに保存する前・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(過剰にマスクしてよい)にそのまま沿う判断である。
「利用者が入力するテキスト」はすべて対象とする。 画面ごとの例外を作らない。
| 入力 | 送信先 | 対象 | プレビュー表示 |
|---|---|---|---|
| エラーテキスト | 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 する。
| 項目 | 方針 |
|---|---|
| 学習への利用 | OrcaRouter 側で学習に使わせない設定を確認し、明記する(NFR-S4) |
| 送信内容 | マスキング済みのエラーテキスト・コード断片のみ。メールアドレスや氏名は送らない |
| 利用者への説明 | 入力が外部の LLM に送られること、マスキングを行っていること、および固有名詞は自動では消えないことを UI に明記する。何が伏せられたかは送信前にプレビューで見せる |
「マスキングしているから安全」とは書かない。 正規表現で検出できない形式の秘匿情報は残りうる。 利用者が判断できるよう、何をしていて、何を保証していないかを正直に書く。
| 項目 | 実装 |
|---|---|
| 方式 | メール + パスワード |
| ハッシュ | node:crypto の scrypt(ソルトはユーザーごとに生成) |
| セッション | 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 の入力の扱いの方が本質的に重要。
| 層 | 実装 | 目的 |
|---|---|---|
| フロント | 送信中はボタンを disabled にする |
誤操作による二重送信 |
| サーバ | 冪等性(同じ questionId への二重回答は同じ結果を返す) |
ネットワーク再送 |
| サーバ | セッション作成 10 回 / 時 / ユーザー | 意図的な連打・コスト流出 |
| サーバ | 1 セッション 80,000 トークン上限 | 1 セッションでの暴走 |
連打防止をフロントだけで実装すると、ボタンを押さずに直接 API を叩けば無効化できる。 サーバ側の冪等性がなければ、1 回の操作で LLM を何度でも呼ばせられる。
フロントの制御は UX、サーバの冪等性が防御。 両方を実装する。
カウンタはデータストアの既存アイテム内に持ち、
レート制限のためだけにアクセス枠を消費しない(turns.length などで判定できる)。
| 項目 | 方針 |
|---|---|
| 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 の出力は信頼できない入力として扱う。
ユーザーが貼り付けたエラーテキストに <script> が含まれていれば、
LLM がそれを引用した文を生成しうる。
// ✗ LLM 出力をそのまま HTML として挿入
optionEl.innerHTML = option.label
// ✓ テキストとして挿入
optionEl.textContent = option.labelフレームワークを使わない構成(ADR-013)では、 React のような自動エスケープがない。ここは意識して守る必要がある。
正直に列挙する。やっていないことを「やった」と書かない。
| 項目 | 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 対策 | ❌ | 実行環境側に委ねる |