2026-09-03
「セキュリティレビューは、毎回かけていました」
引き継いだ社内アプリの SMTP 障害を追ったら、構成・ルーティング・デプロイ経路に4件出てきました。どれもレビューでは出ません。ソースの中に無かったからです。
- どこで
- 当社が社内で使っている取引先向けの支援ツール(前任者が構築し、現担当者が引き継いで実用化)
- 使っているもの
- .NET 8 / ASP.NET CoreMailKitReact SPA(同一オリジンで wwwroot 配信)Azure App ServicePostgreSQLGmail SMTP(Google Workspace)GitHub Actions / OIDC
入口は SMTP 障害。ログの1行で場所が確定しました
取引先向けの一斉配信が全通失敗している、というところから始まりました。App Service のログに出ていたのは認証エラーでも TLS のハンドシェイク失敗でもなく、その手前——名前解決の失敗(Name or service not known)です。つまり MailKit が ConnectAsync でホスト名を引けていない。設定されている SMTP ホストが、もう存在しないホスト名でした。
なぜ古い値が生きていたか。SMTP 構成が DB のテーブルに入っていて、しかも構成解決の優先順位が「DB があれば DB を使う。無ければ環境変数」でした。テナントを移した際に環境変数側は空のまま、アプリは DB の古い値を読み続けます。移設で壊れたのではなく、移設したことが構成に伝わる経路が無かった。
★もう1つ、こちらのほうが重い。送信メソッドの戻り値が void でした。内部で catch して LogError して、そこで終わり。例外は呼び出し元まで飛びません。呼び出し側は「例外が来なかった」を成功と解釈し、配信履歴テーブルに送信済みを書いていました。
握りつぶされた例外は、監視を嘘つきにします。この日、画面上の配信成功率は 100% でした。1通も出ていないのに。
ついでに出てきた3つ
① ルーティングに載っていない画面と、生きたままのコントローラ。メール設定画面のコンポーネントは残っていましたが、ルート定義が無く、URL を直打ちしても SPA のフォールバックで index.html が返るだけ。UI としては到達不能です。ところが対応する API コントローラは生きていて、管理者ロールのトークンさえあれば画面を経由せず構成を書き換えられました。UI を外しても攻撃面は減りません。減るのは、コードを消したときだけです。
② 誰も知らない保守用エンドポイントが4つ。テスト送信、診断、権限確認、デプロイ通知。ルーティングからは辿れず、現担当者は存在すら知らず、アクセスログ上の使用実績もゼロ。診断系は環境の情報を返します。使われていない入口は、守られていない入口です。
③ デプロイが Git を経由していませんでした。公開は Windows 端末で叩くシェルスクリプトで、やっていることは「手元の作業ツリーを zip して App Service に投げる」。ビルドの出所(provenance)がどこにも残りません。古いローカルで実行すれば、他人のコミットが本番から黙って消えます。しかも exit 0 で正常終了します。成果物にコミット SHA が紐づいていないので、戻す先も特定できません。
なぜ、レビューで出なかったのか
現担当者は、変更のたびにセキュリティレビューをかけていました。指摘は出て、その都度直しています。手を抜いた形跡はどこにもありません。
レビューが読むのは差分とソースです。今日出た4件は、どれも構成・配線・供給経路の側にありました。DB と環境変数のどちらを勝たせるか、ルーティングと認可の対応が取れているか、ビルド成果物がどこから来るか、ログが事実を書いているか。
たとえば構成の優先順位は、コードとしては if 1つです。それ単体は脆弱ではない。危険かどうかは「秘密を DB に置いている」「移設が起きうる」という運用の文脈と突き合わせて初めて決まります。ルーティングの欠落も同じで、静的解析からは未使用コードにしか見えません。デプロイ経路に至ってはリポジトリの外です。
危ない書き方ではなく、危ない置き方でした。1行ずつ読んでも出てきません。
そもそも、当社も同じことをしていました
このアプリのリポジトリは、もともと個人アカウントの下にあり、しかも public でした。そして手順書の Markdown に、Git の認証情報が平文で書かれていました。
気づいたのは環境の移設作業中です。作業に使っていた AI に指摘されて、初めて分かりました。急いで private に切り替え、Organization 配下へ移し、認証情報を入れ替えました。
当時、悪意も油断もありません。AI に手伝わせる前提の書き方が、まだ誰の中にも定まっていない時期でした。「手順書に全部書いておけば次の人が困らない」——親切のつもりです。
★ここが今日いちばん共有したいところです。private に戻しても、public だった期間のクローンとフォークは戻りません。取り消せるのは資格情報そのものの revoke だけで、可視性の変更は止血であって治療ではない。しかもファイルを消しても Git の履歴には残ります。消えたのは HEAD からだけです。
GitHub の secret scanning は public リポジトリを見ますが、当時 push protection は掛かっていませんでした。機械が拾わなかったから安全だった、ではありません。拾わなかっただけです。
同じ日に、自分たちの鍵にも同じ形が見つかりました
人のことは言えません。同じ日、当社は自分たちの OIDC の登録を数え直して、性格の違う2系統が1つの App Registration を共有していることに気づきました。前日にリポジトリは分けたのに、federated credential は分けていなかった。
さらに、その credential の subject に pull_request の形が入っていました。ブランチを問わず、PR が立った時点でトークンが出る構成です。実際にそれを使っているワークフローは1つもありませんでした。ただの、誰も通らない出口です。
分離の境界は、リポジトリ名ではなく credential 側にあります。ディレクトリを分けた時点で分かれた気になりますが、鍵が同じなら分かれていません。
App Registration を用途ごとに3つへ分割し、subject は repo:<org>/<repo>:ref:refs/heads/main の形だけに限定、ロール割り当てのスコープも単一リソースまで絞りました(GitHub の subject には ID を含む形と含まない形があるため、両方を登録しています)。
そのうえで、デプロイのたびに「見えてはいけないリソースグループが見えないこと」を実際に叩いて確かめ、見えたらその場で赤にして止めます。権限は宣言ではなく、実測するものだと考えています。
直したこと
構成の優先順位を反転しました。App Service のアプリケーション設定(=プロセスには環境変数として入る)を第一優先にし、DB は後ろへ。ASP.NET Core は環境変数名の二重アンダースコアを構成キーの階層区切りとして読むので、Email__SmtpHost が Email:SmtpHost になります。保存するとアプリが再起動して反映されるため、再デプロイは不要。アプリにログインできる人が1人もいなくても直せる状態になりました。
返信先は Reply-To で解決しました。Gmail は認証アカウントと異なる From を書き換えますが、Reply-To は書き換えません。差出人と返信先を別アドレスにしたいときは、ここに寄せるのが唯一の手です。値が不正なときは警告を出したうえで送信は続けます(返信先が付かないことより、送れないことのほうが重い)。
送信メソッドを Task<bool> に変え、呼び出し側で分岐させました。送れていないのに送信済みと残ることは、もうありません。
到達不能な画面と、その裏のコントローラ、保守用エンドポイント4つを削除しました。削除の確認は、SPA のフォールバック挙動を逆に使っています——存在しないパスは index.html を 200 で返すので、以前 401 を返していたパスが 200 の HTML になっていれば、ハンドラが消えたということです。
公開を GitHub Actions + OIDC に置き換えました。長期シークレットは1つも置きません。main への push だけがトリガー、ビルド → 鍵のスコープ確認 → デプロイ → ヘルスエンドポイントを最大5分ポーリング、の順です。zip を渡せたことと、動いていることは別なので、最後に実際に叩くまでを1本に入れました。
手動デプロイのスクリプトは、消さずに「もう使いません」と表示して exit 1 する形に置き換えました。消すとファイルが無いだけですが、残して止めれば、なぜ止まっているかを次に叩いた人へ伝えられます。
その初日に、自分のデプロイが赤くなりました
正直に書きます。この自動化を入れた初日、最初のデプロイがビルドで落ちました。CS0126——戻り値を void から Task<bool> に変えたときに、早期リターンの return; を1つ直し忘れていた、それだけです。
落ちたことより、いつ気づいたかのほうが問題でした。main に入ったあとです。作業環境に .NET SDK が無く、egress も塞がっていて SDK を取りに行けないため、手元でコンパイルできていませんでした。
本番には何も届いていません。順番がビルド → 鍵の確認 → デプロイだったからです。壊れた成果物は、そもそも作られていない。
対処として、main 以外のブランチと PR でビルドだけを回すワークフローを足しました。こちらは Azure に一切触りません(鍵を使わないので、通す条件がまったく違う)。自動化の価値は、失敗を早く出すことのほうにあります。
当社が設計として持ち帰ったこと
握りつぶされた例外は、監視を嘘つきにします。送信・課金・外部連携のような副作用のある処理に、戻り値を持たない関数を作らない。
構成の優先順位は仕様です。DB と環境変数のどちらが勝つかを書いていないシステムは、移設のときに必ず壊れます。しかも静かに壊れます。
UI を消しても攻撃面は消えません。ルーティングから外れた画面は、対応する API がまだ生きている目印として読みます。
使われていないエンドポイントは、消すのが最も安全な保護です。監視も認知もされていない入口は、実質的に誰の管理下にもありません。
ビルドの出所が無いデプロイは、ロールバックもできません。人が手元から publish するかぎり、その人のローカルが本番の定義になります。
分離の境界はリポジトリ名ではなく credential 側にあります。置き場所を分けた日に、鍵も分けたかを確かめる。
可視性を private に戻すのは止血、revoke が治療です。取り消せるものと取り消せないものを分けて数えます。
レビューは書いてあるものしか見ません。引き継ぎで失われるのはコードではなく、コードの外側——構成の置き場所、供給経路、繋がっていない配線です。前任者と一緒にいなくなります。
当社が既存環境を引き受けるとき、最初に見るのはこの4つ(構成の解決順・認可と到達性の対応・デプロイ経路・ログの誠実さ)です。ソースを読むのは、そのあとです。