applippli.cloud

現場の記録

実際に起きたことと、どう直したか

当社が自社で運用しているシステムで、実際に起きたことを書いています。 うまくいった話だけではなく、なぜそうなったのか、どこを直したのかまで残します。

他のページは、ITに詳しくない方に向けて書いていますが、 ここだけは詳しい方向けです。 用語は避けきっていませんが、用語で誤魔化さないようにしています。

ここに書いてあることの前提

  • ・すべて実際に起きたことです。予定や構想は書きません
  • ・当社が自社で運用しているシステムの話です。お客様の事例ではありません
  • ・実際に測っていない数字(「何時間かかる」等)は書きません

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つ(構成の解決順・認可と到達性の対応・デプロイ経路・ログの誠実さ)です。ソースを読むのは、そのあとです。

2026-09-02

作った本人が「何もしていないのに直っている」と驚いた話

Google Workspace 移行で送信できなくなったツールを、コード0行・デプロイ0回で復旧しました。触ったのは App Service のアプリケーション設定5つだけです。

どこで
当社が自社で運用・公開しているセキュリティ診断ツール(パスワードの危険度チェック)
使っているもの
Node.jsNodemailer 系の SMTP 送信Azure App ServiceGmail SMTP(Google Workspace)

起きたこと

社内メールを Google Workspace へ移したところ、このツールが結果メールを送れなくなりました。移行前の送信用アカウントが、そのままでは認証を通らなくなったためです。

作った当社の担当者は、この時点では「自分がコードを直すことになる」と考えていました。

触ったところ

App Service のアプリケーション設定に入っている5つ——SMTP ホスト、ポート、暗号化方式、認証アカウント、アプリパスワード——を新しいアカウントのものに差し替えただけです。

キーを足してはいません。5つとも最初から存在していました。

コードは1行も変えていません。デプロイもしていません。アプリケーション設定の保存でプロセスが再起動し、新しい環境変数を読み直します。

なぜ、それだけで直ったのか

このツールは、接続先も認証情報も設定から読むだけで、値をコード側に一切持っていませんでした。いわゆる 12-factor の III そのもので、外側を入れ替えた時点で送信先も認証アカウントも切り替わります。

1つだけ確信が持てなかったのが From です。差出人を指定するキーが無かったため、コード内にアドレスが直書きされている可能性が残っていました。1通送って確かめたところ、Gmail 側が認証アカウントで From を上書きする挙動に乗っており、こちらも自動で切り替わっていました。

smtp-relay を選ばない理由

Google Workspace には IP アドレスで許可を出す SMTP relay の口があります。当社はこれを使いません。App Service のような共有 egress からだと送信元 IP が固定できず、認証に到達する前に接続ごと拒否されることがあり、しかも毎回ではなく散発的に失敗します。切り分けに時間がかかる壊れ方です。

そのため常に認証を通す口(smtp.gmail.com / 587 / STARTTLS)を使っています。日次の送信上限は下がりますが、失敗が決定的に出るほうを取りました。

少量のテストではこの種の失敗は再現しません。数通ならまず全部通ります。確かめるときは、翌営業日に実際の量で確かめます。

自分でやると、どこで止まるか

同じことを自力でやる場合、まず「どのリソースがそのサイトなのか」の特定から始まります。カスタムドメインは名前がまったく違うリソースに紐づいていることがあり、ドメイン名で検索しても出てきません。

そのうえで、どのキーを差し替えるのか、なぜ relay ではいけないのか、1つ保存するたびに何が再起動するのか——を、ポータルを開いたまま判断していくことになります。

当社に依頼された場合、お客様の作業はゼロです。この件では、ツールを書いた本人でさえ、何が起きたか知らないまま直っていました。

当社が設計として持ち帰ったこと

復旧が速かったのは、接続先と認証情報が最初からコードの外に出ていたからです。中に書いてあれば、修正・テスト・再デプロイの3工程になっていました。

当社が新しく作るものは、あとから差し替わりうる値を、外から差し替えられる形に置きます。この一件はその裏付けです。

早く直せることが売りではありません。お客様側で障害として発生しないことが売りです。

この記録は、これから増えていきます

当社は applippli Launch の仕組みを、いま実際に作りながら動かしています。 その途中で起きたことを、起きた順に足していきます。