個人開発の問い合わせ窓口は、静的HTMLを1枚置くだけでも作れます。ところが、その1枚を自動デプロイするために長期間有効なAWSアクセスキーをGitHubへ登録すると、窓口より重い認証情報を抱えることになります。
ここで感じる「小さな個人開発なのに、IAMまで設計するのは大げさでは」という迷いは自然です。ただし、必要なのは企業並みの複雑な運用ではありません。長寿命の秘密を置かず、リポジトリ・ブランチ・配置先を固定するところまでなら、小さな構成でも再現できます。
この記事では、次の導線を作ります。
README
↓
S3上の問い合わせ案内ページ
├─ 不具合・質問 → GitHub Issue Form
│ ↓
│ メンテナーがコメント
│ ↓
│ 投稿者へGitHub通知
│
└─ 脆弱性報告 → Private Vulnerability Reporting
↓
非公開で返信
S3へのデプロイにはGitHub ActionsとAWS OIDCを使います。AWSアクセスキーはGitHub Secretsへ登録しません。
結論
最小構成では、問い合わせ本文を独自DBへ保存する必要はありません。
- 問い合わせページは「適切な受付先を選ぶルーター」にする
- 公開してよい質問はGitHub Issue Formで受ける
- 脆弱性情報はGitHubの非公開報告へ分離する
- 返信はIssueまたは非公開スレッド上で行う
- S3への配置権限はGitHub OIDCで一時発行する
- IAMロールは対象リポジトリ、
mainブランチ、対象バケットに限定する
OIDCが解決するのは、GitHub Actionsへ長期間有効なAWSアクセスキーを保存する問題です。ワークフローや依存Actionが侵害された場合の操作まで無害になるわけではありません。そのため、認証方式の変更と最小権限化をセットで扱います。
前提
以下を用意します。
- AWSアカウント
- GitHubリポジトリ
- ローカルで利用できるAWS CLI
- AWS IAM Identity Centerなどによる一時的な管理セッション
- GitHub Issuesを有効にしたリポジトリ
例では次の値を使います。自分の環境に置き換えてください。
export AWS_REGION=ap-northeast-1
export GITHUB_OWNER=your-name
export GITHUB_REPO=your-repository
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export SUPPORT_BUCKET="support-page-${AWS_ACCOUNT_ID}"
export DEPLOY_ROLE_NAME=github-support-page-deployer
AWS CLIへ固定アクセスキーを設定する代わりに、可能なら一時セッションを使います。
aws sso login --profile your-profile
export AWS_PROFILE=your-profile
aws sts get-caller-identity
先に決める境界
実装前に、問い合わせを次の3種類へ分けます。
| 種類 | 受付先 | 公開範囲 | 返信方法 |
|---|---|---|---|
| 使い方、不具合、機能要望 | GitHub Issue | 公開 | Issueコメント |
| 脆弱性、攻撃手順、未公開の欠陥 | Private Vulnerability Reporting | 非公開 | 非公開スレッド |
| 契約、個人情報、アカウント固有情報 | 別の非公開窓口 | 非公開 | 同じ会話経路 |
重要なのは、問い合わせフォームを作ることよりも、利用者が秘密情報を公開Issueへ書かないよう入口で判断させることです。
また、GitHubの通知を無効にしている投稿者には返信通知が届かない可能性があります。案内ページには「返信はGitHub上で行う」と明記します。
コード1:問い合わせのデータ形をIssue Formで固定する
.github/ISSUE_TEMPLATE/support.ymlを作成します。
name: サポート問い合わせ
description: 使い方、不具合、機能要望を連絡する
title: "[Support]: "
labels:
- support
body:
- type: markdown
attributes:
value: |
このIssueは公開されます。
APIキー、パスワード、個人情報、未公開の脆弱性は記載しないでください。
- type: dropdown
id: category
attributes:
label: 種別
options:
- 使い方の質問
- 不具合
- 機能要望
validations:
required: true
- type: textarea
id: summary
attributes:
label: 内容
description: 期待した結果と実際の結果を記載してください。
placeholder: |
期待した結果:
実際の結果:
再現手順:
validations:
required: true
- type: input
id: version
attributes:
label: バージョンまたはコミット
placeholder: v1.2.0
- type: checkboxes
id: public-check
attributes:
label: 公開情報の確認
options:
- label: 秘密情報や個人情報を含んでいないことを確認しました
required: true
このYAMLが問い合わせの最小データモデルです。カテゴリ、本文、対象バージョン、公開可否の確認を持ちます。
Issueへ登録された後は、状態をラベルで管理できます。
support 受付済み
needs-info 追加情報待ち
answered 回答済み
closed 解決または終了
専用の管理画面を作らなくても、検索条件を保存すれば未返信を確認できます。
is:issue is:open label:support -label:answered
コード2:S3へ置く案内ページ
public/index.htmlを作ります。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>Support</title>
<style>
body { max-width: 720px; margin: 48px auto; padding: 0 20px;
font-family: system-ui, sans-serif; line-height: 1.7; }
.route { border: 1px solid #ddd; border-radius: 12px;
padding: 20px; margin: 16px 0; }
.warning { color: #9a3412; }
a { overflow-wrap: anywhere; }
</style>
</head>
<body>
<main>
<h1>お問い合わせ</h1>
<p>内容に応じて窓口を選択してください。</p>
<section class="route">
<h2>使い方・不具合・機能要望</h2>
<p>内容と返信は公開されます。</p>
<p class="warning">APIキー、個人情報、脆弱性の詳細は書かないでください。</p>
<a href="https://github.com/OWNER/REPOSITORY/issues/new?template=support.yml">
公開Issueを作成する
</a>
</section>
<section class="route">
<h2>脆弱性の報告</h2>
<p>公開Issueではなく、非公開の報告機能を利用してください。</p>
<a href="https://github.com/OWNER/REPOSITORY/security/advisories/new">
非公開で脆弱性を報告する
</a>
</section>
<p>メンテナーからの返信は、それぞれのGitHubスレッド上で行います。</p>
</main>
</body>
</html>
OWNERとREPOSITORYを実際の値へ変更します。
Private Vulnerability Reportingは、リポジトリのSettingsから有効化してください。利用できない場合は、セキュリティ専用メールアドレスなど、公開Issueとは異なる経路を用意します。
手順1:問い合わせページ専用のS3バケットを作る
この例では、専用バケット内のオブジェクトだけを公開します。アプリのログやバックアップと同じバケットへ置かないでください。
aws s3api create-bucket \
--bucket "$SUPPORT_BUCKET" \
--region "$AWS_REGION" \
--create-bucket-configuration LocationConstraint="$AWS_REGION"
ACLは無効のままにし、バケットポリシーだけで読み取りを許可します。
aws s3api put-public-access-block \
--bucket "$SUPPORT_BUCKET" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=false'
bucket-policy.jsonを作ります。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlySupportPage",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
}
]
}
バケット名を置換して適用します。
sed "s/BUCKET_NAME/${SUPPORT_BUCKET}/g" bucket-policy.json > /tmp/bucket-policy.json
aws s3api put-bucket-policy \
--bucket "$SUPPORT_BUCKET" \
--policy file:///tmp/bucket-policy.json
公開URLは次の形式です。
https://support-page-ACCOUNT_ID.s3.ap-northeast-1.amazonaws.com/index.html
独自ドメインやディレクトリ単位の表示が必要なら、S3を非公開に戻し、CloudFrontのOrigin Access Controlを利用する方が適しています。
手順2:GitHub用OIDCプロバイダーを確認する
AWSアカウント内にGitHub Actions用OIDCプロバイダーがあるか確認します。
aws iam list-open-id-connect-providers
対象ARNは次の形式です。
arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com
存在しない場合は、IAMの「IDプロバイダ」から次を登録します。
プロバイダURL: https://token.actions.githubusercontent.com
Audience: sts.amazonaws.com
OIDCプロバイダーはAWSアカウント単位のリソースです。別プロジェクトがすでに作成している場合、重複して作らず共有します。
手順3:リポジトリとブランチを固定した信頼ポリシーを作る
trust-policy.jsonを作ります。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:GITHUB_OWNER/GITHUB_REPO:ref:refs/heads/main"
}
}
}
]
}
値を置換してロールを作ります。
sed \
-e "s/AWS_ACCOUNT_ID/${AWS_ACCOUNT_ID}/g" \
-e "s/GITHUB_OWNER/${GITHUB_OWNER}/g" \
-e "s/GITHUB_REPO/${GITHUB_REPO}/g" \
trust-policy.json > /tmp/trust-policy.json
aws iam create-role \
--role-name "$DEPLOY_ROLE_NAME" \
--assume-role-policy-document file:///tmp/trust-policy.json
subをワイルドカードにすると、意図しないブランチやタグからもロールを引き受けられる可能性があります。まずmainだけに限定し、必要になってから条件を追加します。
手順4:配置先バケットだけを書き込めるようにする
deploy-policy.jsonを作ります。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::BUCKET_NAME"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
}
]
}
適用します。
sed "s/BUCKET_NAME/${SUPPORT_BUCKET}/g" deploy-policy.json > /tmp/deploy-policy.json
aws iam put-role-policy \
--role-name "$DEPLOY_ROLE_NAME" \
--policy-name DeploySupportPageOnly \
--policy-document file:///tmp/deploy-policy.json
AdministratorAccessやAmazonS3FullAccessは不要です。OIDCで一時認証へ移行しても、権限が広ければワークフロー侵害時の影響は大きいままです。
コード3:GitHub Actionsから一時認証でデプロイする
GitHubのSettings > Secrets and variables > Actions > Variablesへ、次の値を登録します。
AWS_ROLE_ARN=arn:aws:iam::ACCOUNT_ID:role/github-support-page-deployer
AWS_REGION=ap-northeast-1
SUPPORT_BUCKET=support-page-ACCOUNT_ID
これらは秘密情報ではありません。AWSアクセスキーとシークレットアクセスキーは登録しません。
.github/workflows/deploy-support-page.ymlを作ります。
name: Deploy support page
on:
push:
branches: [main]
paths:
- "public/**"
- ".github/workflows/deploy-support-page.yml"
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Configure temporary AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
role-session-name: github-support-page
- name: Show caller identity
run: aws sts get-caller-identity
- name: Deploy
run: |
aws s3 sync public/ "s3://${{ vars.SUPPORT_BUCKET }}/" \
--delete \
--cache-control "public,max-age=300"
id-token: writeにより、GitHub ActionsはOIDCトークンを取得できます。AWSはトークンのaudとsubを信頼ポリシーで検証し、条件が一致した実行にだけ短時間のAWS認証情報を返します。
本番運用では、利用するActionをタグではなく検証済みのコミットSHAへ固定することも検討してください。
手順5:READMEから窓口へつなぐ
READMEにはフォームを直接埋め込まず、問い合わせの公開範囲を説明する案内ページへリンクします。
## Support
使い方、不具合、脆弱性の報告先は、次のページから選択してください。
- [お問い合わせ窓口](https://support-page-ACCOUNT_ID.s3.ap-northeast-1.amazonaws.com/index.html)
公開Issueには、APIキー、個人情報、未公開の脆弱性を記載しないでください。
これで、READMEから受付、メンテナーの返信、投稿者へのGitHub通知までが一つの経路になります。
確認方法
1. 長期間有効なAWSキーがないことを確認する
GitHub ActionsのSecretsを確認し、次の名前や同等の値が登録されていないことを確認します。
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
リポジトリ内も検索します。
git grep -nE 'AKIA[0-9A-Z]{16}|AWS_SECRET_ACCESS_KEY' || true
この検索だけで漏えいがないとは証明できません。過去にアクセスキーをコミットした可能性がある場合は、履歴から消すだけでなく、IAM側で無効化・削除してください。
2. OIDC経由であることを確認する
ActionsのShow caller identityに、作成したIAMロールが表示されることを確認します。
CloudTrailでも確認できます。
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
--max-results 10
3. 別ブランチでは認証できないことを確認する
検証用ブランチからworkflow_dispatchを実行します。信頼ポリシーをmainへ限定していれば、AWS認証の段階で失敗するのが期待結果です。
失敗させる確認は、正常系のデプロイと同じくらい重要です。
4. 問い合わせから返信まで確認する
別のGitHubアカウントまたは協力者に、次の操作を依頼します。
- READMEから案内ページを開く
- 公開Issueを選ぶ
- Issue Formを送信する
- メンテナーが回答コメントを投稿する
- 投稿者へGitHub通知が届くことを確認する
-
answeredラベルを付ける
併せて、脆弱性報告リンクが公開Issueではなく非公開画面へ遷移することも確認します。
AIに任せられる範囲と、人が決める範囲
生成AIは、IAMポリシー、Issue Form、GitHub Actionsのひな型を作る用途には役立ちます。一方で、次の境界はリポジトリの事情を知る人が決める必要があります。
- どのリポジトリをAWSが信頼するか
- どのブランチからデプロイを許可するか
- 公開Issueへ書いてよい情報は何か
- 脆弱性と通常問い合わせをどう分離するか
-
s3:DeleteObjectを本当に許可するか
AIが生成したIAMポリシーが構文上正しくても、Resource: "*"や広すぎるsub条件を含んでいれば、運用上は不適切です。AIの価値は判断の代行ではなく、レビュー対象となる初稿を速く作ることにあります。
使える次の一手は、生成結果へ次の質問を機械的に当てることです。
この権限は、対象リポジトリ・mainブランチ・対象S3バケット以外でも使えないか?
答えを説明できない権限は、付与する前に分割または削除します。
非公開の一般問い合わせも必要な場合
GitHub Issuesは公開問い合わせ、Private Vulnerability Reportingはセキュリティ報告に向いています。一方、個人情報を含む相談や、GitHubアカウントを持たない利用者からの連絡には別経路が必要です。
選択肢は次の通りです。
- 専用メールアドレスを案内する
- 自前のAPI、保存先、返信画面を実装する
- 外部の問い合わせサービスへ案内ページからつなぐ
実装例の一つとして、Knocketの共有可能な問い合わせページをREADMEからリンクできます。訪問者はアカウントなしで会話を開始でき、メッセージをTelegramへ転送し、Telegramでの引用返信を訪問者側へ返せます。自前の問い合わせバックエンドを持たずに、非公開の一般問い合わせを追加したい場合の選択肢です。
この場合も、公開Issue、脆弱性報告、一般の非公開問い合わせを案内ページ上で混ぜず、目的別に表示するのが重要です。
注意点
- OIDCへ移行しても、過去に発行したアクセスキーは自動で無効になりません。不要なキーをIAMで削除してください。
- OIDCは認証情報の長寿命化を防ぎますが、悪意あるワークフローによるS3改ざんは防ぎません。ブランチ保護とコードレビューも必要です。
-
aws s3 sync --deleteは、配置元にないファイルを削除します。手動配置したファイルを同じバケットへ混在させないでください。 - S3バケットは問い合わせページ専用にし、ログ、バックアップ、ユーザーアップロードを置かないでください。
- アカウントIDを含むバケット名は推測防止策ではありません。公開してよいオブジェクトだけを置きます。
- Issueは公開情報です。注意書きだけに依存せず、秘密情報を投稿しない確認項目をIssue Formへ入れます。
- GitHubの通知設定によっては返信を見落とす可能性があります。READMEと案内ページの両方に返信場所を明記します。
- 独自ドメイン、WAF、厳密なキャッシュ制御が必要なら、公開S3を最終形にせずCloudFront経由へ移行します。
問い合わせ導線で守るべきものは、メッセージだけではありません。デプロイのために追加した認証情報も、同じ設計対象です。受付方法を増やす前に、誰が、どの入口から、どこへ配置できるかを固定すると、小さな個人開発でも運用可能な境界になります。
開示:筆者は Knocket の開発・運営に関わっています。本記事では中立的なランキングではなく、実装例の一つとして紹介します。