From 829ff6410bf2500429cc4a64700cc4c6c55ef959 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 19 May 2026 08:40:37 +0900 Subject: [PATCH 1/2] feat: add subdomain takeover defense runbook and automated monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stretch3 が 2026-05-18 に GitHub Pages サブドメイン乗っ取りを受けた事例を 受けて、smalruby 組織が管理する smalruby.app / smalruby.jp の防御を ドキュメント化し、自動監視を追加。 防御施策自体 (Org level domain verification / 2FA enforcement / HTTPS 強制 / 不要 fork archive / CAA records / ACM 未使用証明書削除) はこの PR の範囲外で 適用済み。本 PR は記録 (Runbook) と継続監視 (自動チェック workflow) を加える。 New files: - docs/security/github-pages-subdomain-takeover.md Threat model、6 層の防御、ドメイン/リポジトリ棚卸し、平常時運用ルール、 定期チェック、緊急時プレイブック。 - .github/workflows/security-monitor.yml 毎日 04:00 JST + workflow_dispatch。13 自動チェック (DNS verification TXT × 2、CAA × 8 entries、Pages 設定 × 5 repo、コンテンツ改竄 × 2 URL) を実行し、異常検知時に security-labeled Issue を自動起票 (重複防止)。 default GITHUB_TOKEN のみ使用 (新規 secret 不要)。 ローカル dry-run で 13 check すべて green を確認済み。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/security-monitor.yml | 216 +++++++++++++++ .../github-pages-subdomain-takeover.md | 257 ++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 .github/workflows/security-monitor.yml create mode 100644 docs/security/github-pages-subdomain-takeover.md diff --git a/.github/workflows/security-monitor.yml b/.github/workflows/security-monitor.yml new file mode 100644 index 00000000000..01f78099fe3 --- /dev/null +++ b/.github/workflows/security-monitor.yml @@ -0,0 +1,216 @@ +name: security-monitor + +# Subdomain takeover 防御の自動監視。 +# 詳細は docs/security/github-pages-subdomain-takeover.md を参照。 + +on: + schedule: + # 毎日 19:00 UTC = 04:00 JST + - cron: '0 19 * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: security-monitor + cancel-in-progress: false + +jobs: + check: + name: GitHub Pages subdomain takeover defense check + runs-on: ubuntu-latest + steps: + - name: Install dig + run: | + sudo apt-get update -qq + sudo apt-get install -y dnsutils + + - name: Run checks + id: checks + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + ERRORS=() + + check_dns_txt() { + local domain="$1" + local txt + txt=$(dig +short "_github-pages-challenge-smalruby.$domain" TXT) + if [ -z "$txt" ]; then + ERRORS+=("DNS verification TXT missing for $domain — Org-level domain verification at risk") + else + echo "✓ DNS verification TXT present for $domain" + fi + } + + check_caa() { + local domain="$1" + shift + local caa + caa=$(dig +short "$domain" CAA) + if [ -z "$caa" ]; then + ERRORS+=("CAA records missing entirely for $domain — any CA can issue certificates") + return + fi + local missing=() + for required in "$@"; do + if ! echo "$caa" | grep -qF "$required"; then + missing+=("$required") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + ERRORS+=("CAA $domain: missing entries — $(IFS=', '; echo "${missing[*]}")") + else + echo "✓ CAA records OK for $domain" + fi + } + + check_pages() { + local repo="$1" + local expect_verified="$2" + local data + data=$(gh api "repos/smalruby/$repo/pages" 2>/dev/null) + if [ -z "$data" ]; then + ERRORS+=("Pages $repo: API returned empty (repo deleted / Pages disabled / API access denied)") + return + fi + local https protected cname + https=$(echo "$data" | jq -r '.https_enforced') + protected=$(echo "$data" | jq -r '.protected_domain_state // "n/a"') + cname=$(echo "$data" | jq -r '.cname // "(none)"') + if [ "$https" != "true" ]; then + ERRORS+=("Pages $repo (cname=$cname): https_enforced=$https (expected: true)") + fi + if [ "$expect_verified" = "yes" ] && [ "$protected" != "verified" ]; then + ERRORS+=("Pages $repo (cname=$cname): protected_domain_state=$protected (expected: verified)") + fi + echo "✓ Pages $repo: cname=$cname https=$https protected=$protected" + } + + check_content() { + local url="$1" + local needle="$2" + local http body + http=$(curl -sL --max-time 15 -o /tmp/body.html -w '%{http_code}' "$url") + if [ "$http" != "200" ]; then + ERRORS+=("Content check $url: HTTP $http (expected: 200) — possible DNS or Pages disruption") + return + fi + if ! grep -qi "$needle" /tmp/body.html; then + ERRORS+=("Content check $url: did not contain '$needle' — possible takeover or unrelated content") + else + echo "✓ Content check $url: '$needle' present, HTTP 200" + fi + } + + echo "=== Layer 1: DNS verification TXT records ===" + check_dns_txt smalruby.app + check_dns_txt smalruby.jp + echo "" + + echo "=== Layer 6: CAA records ===" + check_caa smalruby.app \ + '0 issue "letsencrypt.org"' \ + '0 issue "amazon.com"' \ + '0 issue "amazontrust.com"' \ + '0 issue "awstrust.com"' \ + '0 issue "amazonaws.com"' \ + '0 issuewild ";"' + check_caa smalruby.jp \ + '0 issue "letsencrypt.org"' \ + '0 issuewild ";"' + echo "" + + echo "=== Layer 1+2: Pages settings (verified + https_enforced) ===" + check_pages smalruby.app yes + check_pages smalruby.github.com yes + check_pages smalruby3-editor no + check_pages smalruby3-gui no + check_pages dxruby_sdl no + echo "" + + echo "=== Content sanity (takeover detection) ===" + check_content https://smalruby.app/ smalruby + check_content https://smalruby.jp/ smalruby + echo "" + + if [ ${#ERRORS[@]} -eq 0 ]; then + echo "✅ All checks passed" + exit 0 + fi + + echo "❌ ${#ERRORS[@]} error(s) detected:" + printf ' - %s\n' "${ERRORS[@]}" + { + echo "errors<> "$GITHUB_OUTPUT" + exit 1 + + - name: Ensure security label exists + if: failure() && github.event_name == 'schedule' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create security \ + --repo "${{ github.repository }}" \ + --color d73a4a \ + --description "Security-related issue" 2>/dev/null || true + + - name: Open or update alert issue + if: failure() && github.event_name == 'schedule' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ERRORS: ${{ steps.checks.outputs.errors }} + run: | + TITLE="[security-monitor] Subdomain takeover defense check failed" + REPO="${{ github.repository }}" + RUN_URL="${{ github.server_url }}/$REPO/actions/runs/${{ github.run_id }}" + + BODY=$(cat < **🆕 Smalruby 独自** — smalruby 組織が管理する全 GitHub Pages ドメインのサブドメイン乗っ取り (Subdomain takeover) 対策の運用ドキュメント。stretch3 の乗っ取り事例 (2026-05-18) を受けて整備。 + +## 目的 + +`smalruby.app` / `smalruby.jp` および関連サブドメインで、stretch3 と同種の GitHub Pages サブドメイン乗っ取りを受けないよう、防御の現状を記録し、平常時運用と緊急時対応を定義する。 + +## Threat Model + +### 攻撃ベクトル + +GitHub Pages のサブドメイン乗っ取りは以下の流れで成立する: + +1. ドメイン所有者の DNS が GitHub Pages の共有 IP (`185.199.108-111.153`) または `.github.io` への CNAME を指す +2. その先で配信していたリポジトリの Pages 設定が**何らかの理由で外れる**: + - リポジトリが削除される / private 化される + - リポジトリの Pages カスタムドメイン設定が削除される + - Org の billing 状態変更で Pages が止まる +3. 別の GitHub アカウントが自分のリポジトリで **同じカスタムドメイン**を Pages に設定 → claim 成立 +4. 攻撃者は元のドメインで任意のコンテンツを配信できる + +最大のポイント: **DNS は GitHub Pages の共有 IP を指しているだけで、「どの GitHub アカウント宛か」は GitHub のリポジトリ Pages 設定で決まる**。 + +### 影響 + +- ブラウザ側からは `smalruby.app` のコンテンツとして見えるため、信用を悪用したフィッシング / マルウェア配布が可能 +- 既に有効な HTTPS 証明書 (Let's Encrypt) が新所有者にも自動発行される +- SEO 上の被害 (悪意のあるコンテンツがインデックスされる) +- ブランド毀損 + +## 防御の現状 + +5 つのレイヤーで防御している。**Layer 1 が抜けると他のレイヤーだけでは止められない**ので最優先。 + +### Layer 1: GitHub Organization レベルでのドメイン検証 (最重要) + +`smalruby.app` と `smalruby.jp` を **Verified custom domain** として GitHub に登録済み。 + +- 設定場所: https://github.com/organizations/smalruby/settings/pages +- 検証用 TXT レコード (DNS に**永続留置必須**、削除すると検証無効化): + - `_github-pages-challenge-smalruby.smalruby.app` + - `_github-pages-challenge-smalruby.smalruby.jp` +- 確認方法: + ```bash + dig +short _github-pages-challenge-smalruby.smalruby.app TXT + dig +short _github-pages-challenge-smalruby.smalruby.jp TXT + gh api repos/smalruby/smalruby.app/pages --jq .protected_domain_state # "verified" + gh api repos/smalruby/smalruby.github.com/pages --jq .protected_domain_state # "verified" + ``` + +効果: smalruby org 以外の GitHub アカウントは `smalruby.app` / `smalruby.jp` および任意の **immediate subdomain (1 段下)** を Pages のカスタムドメインに設定できない。仮に乗っ取りが先行発生していた場合、検証から **7 日後に GitHub が侵害側を強制解除**する。 + +**制約**: immediate subdomain しか保護されない。`a.smalruby.app` は保護されるが、`b.a.smalruby.app` のような 2 段下のサブドメインは別途保護が必要。 + +### Layer 2: HTTPS 強制化 + +全 Pages リポジトリで `https_enforced: true`。HTTP アクセスは自動的に HTTPS にリダイレクトされ、平文での MITM を防ぐ。 + +### Layer 3: Wildcard DNS の禁止 + +`*.smalruby.app` / `*.smalruby.jp` の wildcard DNS レコードは **設定しない**。理由: + +- wildcard があると任意の深いサブドメインが GitHub Pages の共有 IP を指す形になる +- GitHub のドメイン検証は wildcard 経由のレコードを保護対象外とする +- 結果として attacker は `random-string.smalruby.app` のような任意名で claim 可能になる + +### Layer 4: Org の 2FA 必須化 + +- メンバー全員に 2FA を強制 (`two_factor_requirement_enabled: true`) +- メンバーアカウント乗っ取り → リポジトリ Pages 設定改ざん → 配信内容侵害 という経路を遮断 + +### Layer 5: 不要 fork の archive + +役目を終えた fork は archive する。 +- archive 中は push / Issue / PR / Actions すべて停止 → アカウント乗っ取り経由でも push 不可 +- Pages は archive 後も配信継続 (止めたいなら別途 Pages を Unpublish) +- unarchive で完全復元可能 (admin/owner 権限) + +### Layer 6: CAA レコードによる証明書発行 CA の制限 + +`smalruby.app` / `smalruby.jp` の DNS apex に CAA レコードを設定し、**Smalruby が実際に使う CA 以外からの証明書発行を CA 側でブロック**させる深層防御。仮に DNS をのっとられても、攻撃者が任意の CA で `smalruby.app` 名義の証明書を取得する難易度を上げる。 + +| ドメイン | CAA | +|---|---| +| `smalruby.app` | `0 issue "letsencrypt.org"` (GitHub Pages 用) | +| `smalruby.app` | `0 issue "amazon.com"` `0 issue "amazontrust.com"` `0 issue "awstrust.com"` `0 issue "amazonaws.com"` (AWS ACM 用、4 識別子すべて) | +| `smalruby.app` | `0 issuewild ";"` (wildcard 証明書を全 CA で禁止) | +| `smalruby.jp` | `0 issue "letsencrypt.org"` (GitHub Pages 用) | +| `smalruby.jp` | `0 issuewild ";"` (wildcard 証明書を全 CA で禁止) | + +確認方法: +```bash +dig +short smalruby.app CAA +dig +short smalruby.jp CAA +``` + +**新しい CA や wildcard 証明書を使いたくなったときの注意**: `smalruby.jp` 配下に AWS リソースを追加するときは Amazon 系 4 識別子を CAA に追加する。wildcard 証明書 (例: `*.example.smalruby.app`) を取得したくなったら `issuewild` のホワイトリストを CA 名で追加する。**変更を忘れると証明書発行・更新がサイレントに失敗してサービスが停止する**ので、CAA 変更が必要なときは本ドキュメントを更新すること。 + +## ドメイン / リポジトリ棚卸し + +### smalruby が管理するドメイン + +| ドメイン | DNS 管理 | 用途 | サービス | +|---|---|---|---| +| `smalruby.app` (apex) | dnsv.jp | プロダクト本体 | GitHub Pages (`smalruby/smalruby.app`) | +| `api.smalruby.app` + `*.api.smalruby.app` | AWS Route53 | バックエンド API | AWS API Gateway / AppSync / Lambda | +| `smalruby.jp` (apex) | dnsv.jp | プロダクト本体 (旧) + 各 repo 配信中継 | GitHub Pages (`smalruby/smalruby.github.com`) | + +### GitHub Pages を持つリポジトリ + +| Repo | cname | 配信 URL | 状態 | +|---|---|---|---| +| `smalruby.app` | `smalruby.app` | https://smalruby.app/ | 🟢 現役 — smalruby3-editor から CI/CD で gh-pages push | +| `smalruby.github.com` | `smalruby.jp` | https://smalruby.jp/ | 🟢 現役 — 静的ランディング | +| `smalruby3-editor` | (なし) | smalruby.jp/smalruby3-editor/ | 🟢 現役 — エディタ本体配信 | +| `smalruby3-gui` | (なし) | smalruby.jp/smalruby3-gui/ | 🟡 archive 済 — smalruby3-editor monorepo 統合後の後継誘導ページ | +| `dxruby_sdl` | (なし) | smalruby.jp/dxruby_sdl/ | 🟡 active だが長期放置 — 歴史的資産として配信維持 (HTTPS 強制化のみ実施) | + +cname なしの 3 つは `smalruby.github.io//` 経由で配信される。`smalruby.github.io` は smalruby org 専用 namespace なので別組織が claim できない。 + +## 平常時の運用ルール + +### 新しい Pages サイトを立ち上げるとき + +1. 使うドメインが Org level で verified か確認 (apex / immediate subdomain のみ自動保護) +2. 孫サブドメイン以下を使う場合、新たに Org settings で verify を追加 +3. `https_enforced: true` を必ず設定 +4. CI/CD で gh-pages push する場合、`peaceiris/actions-gh-pages` の `cname` パラメータを設定 + +### 既存 Pages サイトを廃止するとき + +**順序を間違えると dangling 状態になる**。必ずこの順: + +1. **先に DNS を切り離す** — A レコード / CNAME を削除 +2. **次にリポジトリ側の Pages 設定を削除** — Settings → Pages → "Unpublish site" +3. しばらく様子を見て再起動の見込みがなければ、Org settings の verified-domains からも該当ドメインを削除可 (TXT レコード自体は残してよい) + +逆順 (リポジトリ側を先に消す) で進めると、DNS が dangling 状態になる時間ができる。Org verification があれば数日は守られるが、その間に外部 probe される可能性は残る。 + +### リポジトリを削除 / archive するとき + +- **削除する場合**: Pages 設定があるならまず DNS を切り離してから削除する +- **archive する場合**: Pages は配信継続するため DNS 操作不要。upstream 追従の自動 Actions は停止する + +### メンバー追加・削除時 + +- 新メンバー追加時は 2FA 設定済みであることを確認 (Org policy で必須化済みなので自動的にブロックされる) +- メンバー離脱時は org からも removed されていることを確認: + ```bash + gh api orgs/smalruby/members --jq '[.[] | .login]' + ``` + +## 定期チェック + +### 自動化されているチェック + +`.github/workflows/security-monitor.yml` で毎日 04:00 JST に以下を自動チェックし、異常があれば `smalruby/smalruby3-editor` リポジトリに Issue を起票: + +- DNS verification TXT の残存 +- CAA レコードの内容 +- 全 Pages リポジトリの `https_enforced` と `protected_domain_state` +- `smalruby.app` / `smalruby.jp` のコンテンツに `Smalruby` 文字列が含まれるか (改竄検知) + +手動実行: GitHub Actions の "security-monitor" workflow → "Run workflow" + +### 月次の手動チェック (自動化されていない項目) + +```bash +# Pages リポジトリ棚卸し (新規追加・削除を検知) +gh api orgs/smalruby/repos --paginate \ + --jq '.[] | select(.has_pages == true) | "\(.name)\tarchived=\(.archived)\tpushed=\(.pushed_at)"' + +# Org の 2FA enforcement (API 経由は admin:org PAT が必要) +gh api orgs/smalruby --jq '.two_factor_requirement_enabled' + +# Outside collaborators (常に 0 であるべき) +gh api orgs/smalruby/outside_collaborators --jq 'length' + +# Pending invitations (古い招待が残っていないか) +gh api orgs/smalruby/invitations --jq '[.[] | {login, role, created_at}]' +``` + +期待値: +- Pages リポジトリ数: 5 (新規追加があれば本ドキュメントに追記) +- `two_factor_requirement_enabled: true` +- outside_collaborators: 0 +- pending invitations: 0 件 or 業務に必要なもののみ + +### 四半期 + +- DNS 管理画面 (dnsv.jp) で `smalruby.app` / `smalruby.jp` のレコード一覧を確認: + - 不要な CNAME / A レコードを削除 + - wildcard レコードが追加されていないか確認 + - `_github-pages-challenge-smalruby.*` の TXT レコードが残っているか確認 +- AWS Route53 で `api.smalruby.app` の hosted zone を確認: + - 不要なサブドメインがないか + - CNAME の指す先 (S3 / CloudFront / API Gateway / AppSync) が実在するか (= AWS リソース dangling 確認) + +### 年次 + +- PAT の expiration 前に再発行 (admin:org 系の権限を保持しているトークン) +- 本 Runbook の内容が現状と乖離していないか見直し +- ブランチ保護ルール / Actions secrets / Webhooks の棚卸し + +## 緊急時対応 — takeover 検知時 + +### 検知シグナル + +- `https://smalruby.app/` または `https://smalruby.jp/` が見覚えのないコンテンツを返す +- ユーザ報告 / SNS / フィードバック経由で異常コンテンツの指摘 +- Google Search Console の異常な検索結果 +- 監視 Action (将来実装) が content-mismatch を検知 + +### 一次対応 + +1. **影響範囲確認** — どのドメイン / サブドメインが乗っ取られたか: + ```bash + curl -sI https://smalruby.app/ | head -10 + curl -sI https://smalruby.jp/ | head -10 + gh api repos/smalruby/smalruby.app/pages --jq . + gh api repos/smalruby/smalruby.github.com/pages --jq . + ``` +2. **DNS を一時切り離し** — 該当ドメインの A レコード / CNAME を削除。ブラウザでは到達不可になる (DNS TTL 分は残る)。これにより被害が拡大しないようにする。 +3. **GitHub Pages リポジトリの状態確認**: + - 自分の管理リポジトリの Pages 設定が消えていないか + - 消えていれば再設定 (cname 再投入) + - `cname` 設定が他の値に書き換わっていないか +4. **乗っ取られた相手リポジトリの特定**: + - 可能なら GitHub Support に通報 + - verified-domain 機能による自動解除 (検証から 7 日後) を待つのが基本路線 +5. **公式アナウンス** — SNS / Discord / メール等で乗っ取り発生中であることをユーザに告知。アクセスを促さない。 + +### 二次対応 + +- 検証済みドメイン (`protected_domain_state: "verified"`) であれば **検証から 7 日後に GitHub が侵害側を強制解除**する +- 7 日以内に元の状態に戻したいなら GitHub Support に escalate + +### 復旧 + +1. DNS を元に戻す (smalruby が運用する正しいリポジトリの Pages を指すよう A レコード再設定) +2. リポジトリ Pages 設定でカスタムドメインを再設定 + verify +3. CI/CD でコンテンツを再デプロイ +4. インシデント記録 (経緯 / 原因 / 復旧手段 / 再発防止) を本ドキュメントに追記 + +## 関連リンク + +- [Verifying your custom domain for GitHub Pages](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/verifying-your-custom-domain-for-github-pages) +- [Managing a custom domain for your GitHub Pages site](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site) +- [About custom domains and GitHub Pages](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/about-custom-domains-and-github-pages) +- [Archiving repositories — GitHub Docs](https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories) +- [Can I take over XYZ? — EdOverflow](https://github.com/EdOverflow/can-i-take-over-xyz) + +## TODO (将来実装) + +- [ ] `*.api.smalruby.app` の AWS dangling 確認スクリプト — S3 / CloudFront / API Gateway / AppSync の取り違え検知 +- [ ] ACM 未使用証明書の自動クリーンアップ (CDK で新しい証明書を発行するたびに古いものが残る、運用上のノイズ削減) From 00e6b7eb688c606bb059e9389c509031a6949faa Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 19 May 2026 10:48:46 +0900 Subject: [PATCH 2/2] refactor(security-monitor): extract checks to scripts/security-monitor.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘 (PR #690) に対応。インライン bash を独立 script に切り出し、 ローカル実行と CI 実行で完全に同じスクリプトが動くようにする。 変更: - scripts/security-monitor.sh (新規, 実行可能): 13 check ロジック。純 bash、 preflight 依存チェック (gh / dig / curl / jq) と CI/local 両対応の ERROR_FILE 機構を持つ。Local: `./scripts/security-monitor.sh`、 CI: workflow から ERROR_FILE 付きで呼ばれる。 - .github/workflows/security-monitor.yml: 138 行削減。 checkout → dig インストール → script 実行 → 失敗時 Issue 起票、 という薄いオーケストレーションだけを担う。Issue 作成は CI 固有の 関心事として workflow 側に残す。Issue 本文にローカル再現コマンド `./scripts/security-monitor.sh` を含める。 - docs/security/github-pages-subdomain-takeover.md: ローカル手動チェックの 手順と GitHub UI からのオンデマンド実行を「自動化されているチェック」 セクションに追記。 ローカルで `./scripts/security-monitor.sh` 実行 → 13 check すべて green、 exit 0 を確認。ERROR_FILE 機構の動作も確認 (成功時は未書き込み)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/security-monitor.yml | 138 ++------------ .../github-pages-subdomain-takeover.md | 27 ++- scripts/security-monitor.sh | 172 ++++++++++++++++++ 3 files changed, 211 insertions(+), 126 deletions(-) create mode 100755 scripts/security-monitor.sh diff --git a/.github/workflows/security-monitor.yml b/.github/workflows/security-monitor.yml index 01f78099fe3..3f952de6ac0 100644 --- a/.github/workflows/security-monitor.yml +++ b/.github/workflows/security-monitor.yml @@ -1,6 +1,7 @@ name: security-monitor # Subdomain takeover 防御の自動監視。 +# チェックロジックは scripts/security-monitor.sh に集約 (ローカル実行可能)。 # 詳細は docs/security/github-pages-subdomain-takeover.md を参照。 on: @@ -22,6 +23,8 @@ jobs: name: GitHub Pages subdomain takeover defense check runs-on: ubuntu-latest steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - name: Install dig run: | sudo apt-get update -qq @@ -31,125 +34,8 @@ jobs: id: checks env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set +e - ERRORS=() - - check_dns_txt() { - local domain="$1" - local txt - txt=$(dig +short "_github-pages-challenge-smalruby.$domain" TXT) - if [ -z "$txt" ]; then - ERRORS+=("DNS verification TXT missing for $domain — Org-level domain verification at risk") - else - echo "✓ DNS verification TXT present for $domain" - fi - } - - check_caa() { - local domain="$1" - shift - local caa - caa=$(dig +short "$domain" CAA) - if [ -z "$caa" ]; then - ERRORS+=("CAA records missing entirely for $domain — any CA can issue certificates") - return - fi - local missing=() - for required in "$@"; do - if ! echo "$caa" | grep -qF "$required"; then - missing+=("$required") - fi - done - if [ ${#missing[@]} -gt 0 ]; then - ERRORS+=("CAA $domain: missing entries — $(IFS=', '; echo "${missing[*]}")") - else - echo "✓ CAA records OK for $domain" - fi - } - - check_pages() { - local repo="$1" - local expect_verified="$2" - local data - data=$(gh api "repos/smalruby/$repo/pages" 2>/dev/null) - if [ -z "$data" ]; then - ERRORS+=("Pages $repo: API returned empty (repo deleted / Pages disabled / API access denied)") - return - fi - local https protected cname - https=$(echo "$data" | jq -r '.https_enforced') - protected=$(echo "$data" | jq -r '.protected_domain_state // "n/a"') - cname=$(echo "$data" | jq -r '.cname // "(none)"') - if [ "$https" != "true" ]; then - ERRORS+=("Pages $repo (cname=$cname): https_enforced=$https (expected: true)") - fi - if [ "$expect_verified" = "yes" ] && [ "$protected" != "verified" ]; then - ERRORS+=("Pages $repo (cname=$cname): protected_domain_state=$protected (expected: verified)") - fi - echo "✓ Pages $repo: cname=$cname https=$https protected=$protected" - } - - check_content() { - local url="$1" - local needle="$2" - local http body - http=$(curl -sL --max-time 15 -o /tmp/body.html -w '%{http_code}' "$url") - if [ "$http" != "200" ]; then - ERRORS+=("Content check $url: HTTP $http (expected: 200) — possible DNS or Pages disruption") - return - fi - if ! grep -qi "$needle" /tmp/body.html; then - ERRORS+=("Content check $url: did not contain '$needle' — possible takeover or unrelated content") - else - echo "✓ Content check $url: '$needle' present, HTTP 200" - fi - } - - echo "=== Layer 1: DNS verification TXT records ===" - check_dns_txt smalruby.app - check_dns_txt smalruby.jp - echo "" - - echo "=== Layer 6: CAA records ===" - check_caa smalruby.app \ - '0 issue "letsencrypt.org"' \ - '0 issue "amazon.com"' \ - '0 issue "amazontrust.com"' \ - '0 issue "awstrust.com"' \ - '0 issue "amazonaws.com"' \ - '0 issuewild ";"' - check_caa smalruby.jp \ - '0 issue "letsencrypt.org"' \ - '0 issuewild ";"' - echo "" - - echo "=== Layer 1+2: Pages settings (verified + https_enforced) ===" - check_pages smalruby.app yes - check_pages smalruby.github.com yes - check_pages smalruby3-editor no - check_pages smalruby3-gui no - check_pages dxruby_sdl no - echo "" - - echo "=== Content sanity (takeover detection) ===" - check_content https://smalruby.app/ smalruby - check_content https://smalruby.jp/ smalruby - echo "" - - if [ ${#ERRORS[@]} -eq 0 ]; then - echo "✅ All checks passed" - exit 0 - fi - - echo "❌ ${#ERRORS[@]} error(s) detected:" - printf ' - %s\n' "${ERRORS[@]}" - { - echo "errors<> "$GITHUB_OUTPUT" - exit 1 + ERROR_FILE: ${{ runner.temp }}/security-monitor-errors.txt + run: ./scripts/security-monitor.sh - name: Ensure security label exists if: failure() && github.event_name == 'schedule' @@ -165,12 +51,18 @@ jobs: if: failure() && github.event_name == 'schedule' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ERRORS: ${{ steps.checks.outputs.errors }} + ERROR_FILE: ${{ runner.temp }}/security-monitor-errors.txt run: | TITLE="[security-monitor] Subdomain takeover defense check failed" REPO="${{ github.repository }}" RUN_URL="${{ github.server_url }}/$REPO/actions/runs/${{ github.run_id }}" + if [ -s "$ERROR_FILE" ]; then + ERRORS=$(cat "$ERROR_FILE") + else + ERRORS="- (詳細不明: ERROR_FILE が空。Run log を参照)" + fi + BODY=$(cat </` 経由で配信される ### 自動化されているチェック -`.github/workflows/security-monitor.yml` で毎日 04:00 JST に以下を自動チェックし、異常があれば `smalruby/smalruby3-editor` リポジトリに Issue を起票: +実体は `scripts/security-monitor.sh` (純 bash、ローカル実行可能)。CI から `.github/workflows/security-monitor.yml` 経由で毎日 04:00 JST に呼ばれる。13 check: -- DNS verification TXT の残存 -- CAA レコードの内容 -- 全 Pages リポジトリの `https_enforced` と `protected_domain_state` -- `smalruby.app` / `smalruby.jp` のコンテンツに `Smalruby` 文字列が含まれるか (改竄検知) +- DNS verification TXT の残存 (2 domain) +- CAA レコードの内容 (`smalruby.app` 6 entries / `smalruby.jp` 2 entries) +- 全 Pages リポジトリの `https_enforced` と `protected_domain_state` (5 repo) +- `smalruby.app` / `smalruby.jp` のコンテンツに `smalruby` 文字列が含まれるか (改竄検知) -手動実行: GitHub Actions の "security-monitor" workflow → "Run workflow" +異常があれば `smalruby/smalruby3-editor` リポジトリに `security` ラベル付き Issue を自動起票 (同タイトルの open issue があれば新規作成せずコメント追記)。 + +#### ローカルから手動チェック + +CI を待たずに今すぐ確認したいとき、CI と完全に同じスクリプトを手元で実行できる: + +```bash +gh auth status # 認証済みであること +./scripts/security-monitor.sh # 13 check を実行 +``` + +すべての check に ✓ が出れば green (exit 0)。失敗があれば標準出力に詳細が表示される (exit 1)。 + +#### GitHub UI からのオンデマンド実行 + +cron を待たずに CI 環境で実行したいとき: GitHub Actions の "security-monitor" workflow → "Run workflow"。`workflow_dispatch` 経由の失敗は Issue を起票しない (手動デバッグ前提)。 ### 月次の手動チェック (自動化されていない項目) diff --git a/scripts/security-monitor.sh b/scripts/security-monitor.sh new file mode 100755 index 00000000000..114f37ba09d --- /dev/null +++ b/scripts/security-monitor.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# scripts/security-monitor.sh +# +# GitHub Pages subdomain takeover defense check. +# +# 13 checks against the smalruby org's GitHub Pages exposure: +# - DNS verification TXT (2 domains) +# - CAA records (smalruby.app: 6 entries, smalruby.jp: 2 entries) +# - Pages settings (5 repos × https_enforced + cname'd repos × protected_domain_state) +# - Content sanity (2 URLs × "smalruby" string presence) +# +# Usage (local): +# gh auth status # must be logged in +# ./scripts/security-monitor.sh # all green → exit 0 +# +# Usage (CI): called by .github/workflows/security-monitor.yml +# +# Env vars (all optional): +# ERROR_FILE Append failure details here (one per line, markdown bullet form). +# Default: /dev/null. CI sets this to a file so the next step can +# read failures and open an issue. +# GH_TOKEN gh CLI token. CI sets this from secrets.GITHUB_TOKEN. +# Locally, gh auth login covers this; do not set unless overriding. +# +# Exit codes: +# 0 all 13 checks passed +# 1 one or more checks failed (details on stdout and in ERROR_FILE) +# 2 required dependency missing (gh / dig / curl / jq) +# +# Runbook: docs/security/github-pages-subdomain-takeover.md + +set -uo pipefail + +ERROR_FILE="${ERROR_FILE:-/dev/null}" +ERRORS=() + +# --- preflight: required commands --- +missing_cmds=() +for cmd in gh dig curl jq; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_cmds+=("$cmd") + fi +done +if [ ${#missing_cmds[@]} -gt 0 ]; then + echo "❌ Required commands missing: ${missing_cmds[*]}" >&2 + echo " Install: gh (github cli), dnsutils (dig), curl, jq" >&2 + exit 2 +fi + +# --- check functions --- + +check_dns_txt() { + local domain="$1" + local txt + txt=$(dig +short "_github-pages-challenge-smalruby.$domain" TXT 2>/dev/null) + if [ -z "$txt" ]; then + ERRORS+=("DNS verification TXT missing for $domain — Org-level domain verification at risk") + else + echo "✓ DNS verification TXT present for $domain" + fi +} + +check_caa() { + local domain="$1" + shift + local caa + caa=$(dig +short "$domain" CAA 2>/dev/null) + if [ -z "$caa" ]; then + ERRORS+=("CAA records missing entirely for $domain — any CA can issue certificates") + return + fi + local missing=() + for required in "$@"; do + if ! echo "$caa" | grep -qF "$required"; then + missing+=("$required") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + local list + list=$(IFS=', '; echo "${missing[*]}") + ERRORS+=("CAA $domain: missing entries — $list") + else + echo "✓ CAA records OK for $domain" + fi +} + +check_pages() { + local repo="$1" + local expect_verified="$2" + local data + data=$(gh api "repos/smalruby/$repo/pages" 2>/dev/null) + if [ -z "$data" ]; then + ERRORS+=("Pages $repo: API returned empty (repo deleted / Pages disabled / API access denied)") + return + fi + local https protected cname + https=$(echo "$data" | jq -r '.https_enforced') + protected=$(echo "$data" | jq -r '.protected_domain_state // "n/a"') + cname=$(echo "$data" | jq -r '.cname // "(none)"') + if [ "$https" != "true" ]; then + ERRORS+=("Pages $repo (cname=$cname): https_enforced=$https (expected: true)") + fi + if [ "$expect_verified" = "yes" ] && [ "$protected" != "verified" ]; then + ERRORS+=("Pages $repo (cname=$cname): protected_domain_state=$protected (expected: verified)") + fi + echo "✓ Pages $repo: cname=$cname https=$https protected=$protected" +} + +check_content() { + local url="$1" + local needle="$2" + local body_file http + body_file=$(mktemp) + trap 'rm -f "$body_file"' RETURN + http=$(curl -sL --max-time 15 -o "$body_file" -w '%{http_code}' "$url") + if [ "$http" != "200" ]; then + ERRORS+=("Content check $url: HTTP $http (expected: 200) — possible DNS or Pages disruption") + return + fi + if ! grep -qi "$needle" "$body_file"; then + ERRORS+=("Content check $url: did not contain '$needle' — possible takeover or unrelated content") + else + echo "✓ Content check $url: '$needle' present, HTTP 200" + fi +} + +# --- run all checks --- + +echo "=== Layer 1: DNS verification TXT records ===" +check_dns_txt smalruby.app +check_dns_txt smalruby.jp +echo "" + +echo "=== Layer 6: CAA records ===" +check_caa smalruby.app \ + '0 issue "letsencrypt.org"' \ + '0 issue "amazon.com"' \ + '0 issue "amazontrust.com"' \ + '0 issue "awstrust.com"' \ + '0 issue "amazonaws.com"' \ + '0 issuewild ";"' +check_caa smalruby.jp \ + '0 issue "letsencrypt.org"' \ + '0 issuewild ";"' +echo "" + +echo "=== Layer 1+2: Pages settings (verified + https_enforced) ===" +check_pages smalruby.app yes +check_pages smalruby.github.com yes +check_pages smalruby3-editor no +check_pages smalruby3-gui no +check_pages dxruby_sdl no +echo "" + +echo "=== Content sanity (takeover detection) ===" +check_content https://smalruby.app/ smalruby +check_content https://smalruby.jp/ smalruby +echo "" + +# --- report --- + +if [ ${#ERRORS[@]} -eq 0 ]; then + echo "✅ All checks passed" + exit 0 +fi + +echo "❌ ${#ERRORS[@]} error(s) detected:" +printf ' - %s\n' "${ERRORS[@]}" +{ + printf -- '- %s\n' "${ERRORS[@]}" +} >> "$ERROR_FILE" +exit 1