GitHub Enterpriseの許可IPアドレスリストを自動更新する方法
はじめに
資材を安全に扱うことについて関心は非常に高いと思います。GitHubのセキュリティを高めるための機能のひとつに「許可IPアドレス」があります。
この機能を利用するとWebや、API、Gitアクセスなどあらゆる通信を接続元のIPアドレスによって制限できます。
マネージドサービスを利用してGitHubと連携する場合も許可設定は必要で、一般的に週次で更新されるIPアドレス範囲のうちいずれかのIPアドレスを使用して接続します。
今回はAzure DevOpsを例にGitHubの許可IPアドレスリストを自動的に登録・更新する仕組みを紹介します。
※多くのIPアドレスを許可する設定となるため一時的な利用を想定しています。
許可IPアドレス機能とは
許可IPアドレス機能はその名の通りアクセスを許可したい接続元IPアドレスをホワイトリストで管理し、リストに載っていない接続元からのアクセスを遮断する機能です。
CIDR表記を使い単一のIPアドレスやアドレス範囲を登録し、機能を有効化するだけで利用開始できるためお手軽にアクセス制限を導入できます。たとえばオフィスのIPアドレスを登録し、オフィス外からのアクセスを遮断するような運用が考えられます。
登録が必要なIPアドレス
冒頭でも説明しましたが、開発メンバーなどOrganization所属メンバーが使用するIPアドレスだけでなく、外部サービスや他システムからのアクセスを受け入れる場合にも許可IPアドレスリストに登録が必要です。
CI/CDにAzure DevOpsのPipelinesを利用する場合、AzureからGitHubにソースコードを取得するためのアクセスをします。そのため許可IPアドレスリストへの登録が必要になります。通常はAzure Pipelinesのセルフホステッドエージェントを利用しIPアドレスを固定しますが、今回は一時的な利用を想定しAzure Pipelinesの利用するIPアドレス範囲を許可します。
Azureのマネージドサービスではあらかじめ公開しているIPアドレス範囲のうちいずれかのIPアドレスを使用します。
※AWSでも同様に定期的に使用するIPアドレス範囲が更新されます。現在使用されているIPアドレス範囲はこちらで確認できます。
許可IPアドレスリスト自動更新の仕組み
許可IPアドレスリストの更新方法
まずは許可IPアドレスの更新方法ですが、GitHub GraphQL APIを使用します。このAPIは登録済IPアドレスの取得、IPアドレスの登録、IPアドレスの削除など許可IPアドレスリストの操作に必要な機能を備えています。
しかし、API仕様として複数のIPアドレス範囲を同時に指定する方法が無く、削除時は登録済のIPアドレス毎に付与された固有IDで指定しなくてはならず、全件削除もできません。API応答も遅いため、API呼び出し回数を減らすために次のような手順としました。
Azureで使用するIPアドレスを取得する
許可IPアドレスリストに登録済のIPアドレスを取得する
登録するIPアドレスと削除するIPアドレスを抽出する
追加されたIPアドレスを登録する
不要になったIPアドレスを削除する
馴染みのある言語を使用して良いと思いますが、特にこだわりが無ければNode.jsやPythonのようなスクリプト言語での実装をお勧めします。引き継ぎ先の運用チームが利用しているツールがPythonで書かれていたため今回は上記処理をPythonで実装しました。
Azureで使用するIPアドレスの取得
IPアドレス範囲はJSONファイルで公開されていますが、URLが変更されるため一度ダウンロードページにアクセスし、JSONファイルのURLを取得する必要があります。
今回はPyQueryモジュールを使用してダウンロードページのHTMLから、JSONファイルのURLを取得しました。また、取得したJSONファイルはAzureが使用するすべてのIPアドレス範囲が記載されているので、必要なIPアドレスだけ取得します。
{サービス名}.{リージョン}でJSONのキーが設定されているので、今回はAzure Pipelinesで使用するAzureDevOps.EastAsiaとAzureCloud.eastasiaのIPアドレス範囲を取得します。
import requests
from pyquery import PyQuery as pq
import json
def get_azure_ip_list():
url = 'https://www.microsoft.com/en-us/download/confirmation.aspx?id=56519'
html = pq(url)
azure_ip_url = html('.file-link-view1')('a').attr('href') # JSONファイルのURLを取得
res = requests.get(url = azure_ip_url).json()
servicetag = ['AzureDevOps.EastAsia', 'AzureCloud.eastasia']
azure_ip_list = []
for value in res['values']:
if value['name'] in servicetag:
azure_ip_list += value['properties']['addressPrefixes']
return azure_ip_list
GitHub GraphQL API呼び出し
後述の登録済IPアドレスの取得、許可IPアドレスの登録、削除に使用するAPI呼び出しに共通で使用する関数を作成します。
ヘッダも全てのAPIで同一のため、ヘッダ生成関数を作成します。post関数に直接記述しても良いのですが、API呼び出しと認証ヘッダ生成は切り離したかったのでこのようにしています。
今回のGitHub APIの認証には個人トークンを使用しています。環境変数に適宜トークンを設定してください。
def post(headers, body):
url = 'https://api.github.com/graphql'
res = requests.post(url=url, json=body, headers=headers).json()
if 'errors' in res:
# エラー時も200が返る
raise Exception
return res
def make_auth_header() -> Dict[str, str]:
pat = os.environ.get("PAT", "")
if pat == None:
raise Exception
headers = {'Authorization': 'token %s' % pat}
return headers
登録済のIPアドレスを取得する
許可IPアドレスリストに登録されているIPアドレスのうち、今回作成するスクリプトで登録したIPアドレスのみを取得します。
一度に取得できるIPアドレスの上限は100件となっています。ページングされるのでカーソル位置を取得しながらループさせます。
IPアドレスリストのDescriptionに特定の文字列を設定するルールとし、それをもとに手動で登録したIPアドレスを除外しています。
取得したIPアドレスリストをIPアドレスをキー、IPアドレスに設定されたIDをバリューとしたDictionaryに入れ替えています。これは後述のIPアドレス登録の処理の中で重複登録しないよう検索して登録する処理を行いたいからです。
取得対象のOrganizationは環境変数で指定しています。適宜Organization名を設定してください。
IP_DESCRIPTION = 'Auto Registered Azure DevOps IP'
def get_registerd_ip_list():
orgname = os.environ.get("ORGANIZATIONNAME", "")
base_query = ('{'
'organization(login: "ORGANIZATIONNAME") {'
'ipAllowListEntries(first:100 CURSOR) {'
'edges {'
'node {'
'allowListValue '
'name '
'id'
'}'
'}'
'pageInfo {'
'hasNextPage '
'endCursor'
'}'
'}'
'}'
'}').replace('ORGANIZATIONNAME', orgname)
header = make_auth_header()
cursor = ''
has_next = True
registerd_ip = {}
while has_next:
# カーソル位置を設定(2回目以降)
if cursor != '':
cursor = 'after:"' + cursor + '"'
query = base_query.replace('CURSOR', cursor)
res = post(headers=header, body={'query': query})
# 取得したIPアドレスのループ
for edge in res['data']['organization']['ipAllowListEntries']['edges']:
node = edge['node']
# 特定の文字列IP_DESCRIPTIONに一致するIPのみ取得
if node['name'].startswith(IP_DESCRIPTION):
registerd_ip[node['allowListValue']] = node['id']
# 次のループ情報の取得
page_info = res['data']['organization']['ipAllowListEntries']['pageInfo']
has_next = page_info['hasNextPage']
cursor = page_info['endCursor']
return registerd_ip
登録・削除するIPアドレスを抽出する
Azureで使用するIPアドレス(ip_addresses)と許可IPアドレスリスト(registerd_ip)を比較し、登録・削除するIPアドレスを抽出します。処理効率だけでいうと許可IPアドレスの登録の際に同時に実行したほうが良いのですが、削除対象のIPアドレスという副産物も出力をしてしまうため今回は関数を分けることにしました。
import copy
def extract_ip_addresses(ip_addresses, registerd_ip):
add_ip = []
remove_ip = copy.deepcopy(registerd_ip)
for ip in ip_addresses:
if ip in registerd_ip:
del remove_ip[ip]
else:
add_ip.append(ip)
return add_ip, remove_ip
IPアドレスを登録する
抽出したIPアドレスを登録します。
IPアドレス登録のAPIはOrganization名ではなくOrganization IDで登録先を指定するため一度IDを取得した後IPアドレスを登録しています。
from concurrent import futures
def register_ip_addresses(ip_addresses):
orgname = os.environ.get("ORGANIZATIONNAME", "")
orgid_query = ('{'
'organization(login: "ORGANIZATIONNAME") {'
'id'
'}'
'}').replace('ORGANIZATIONNAME', orgname)
header = make_auth_header()
res = post(headers=header, body={'query': orgid_query})
id = res['data']['organization']['id']
future_list = []
with futures.ThreadPoolExecutor(max_workers=5) as executor:
for ip in ip_addresses:
future = executor.submit(register, ip, id, header)
future_list.append(future)
_ = futures.as_completed(fs=future_list)
def register(ip, id, header):
register_query = (
'mutation {'
'createIpAllowListEntry('
'input:{'
'allowListValue:"IPADDRESS" '
'isActive:true '
'name:"DESCRIPTION" '
'ownerId:"ORGID"'
'}'
') {'
'clientMutationId'
'}'
'}'
).replace('IPADDRESS', ip).replace('ORGID', id).replace('DESCRIPTION', IP_DESCRIPTION)
res = post(headers=header, body = {'query':register_query})
IPアドレスを削除する
不要になったIPアドレスを削除します。削除の際はIPアドレスではなく、IDを指定します。
def remove_ip_addresses(remove_ip):
header = make_auth_header()
future_list = []
with futures.ThreadPoolExecutor(max_workers=5) as executor:
for ip in remove_ip.keys():
future = executor.submit(remove, remove_ip[ip], header)
future_list.append(future)
_ = futures.as_completed(fs=future_list)
def remove(id, header):
remove_query = (
'mutation {'
'deleteIpAllowListEntry('
'input:{'
'ipAllowListEntryId:"ALLOWLISTENTRYID"'
'}'
') {'
'clientMutationId'
'}'
'}'
).replace('ALLOWLISTENTRYID', id)
res = post(headers=header, body = {'query':remove_query})
更新スクリプトの実行環境
許可IPアドレスリストの更新スクリプトが完成したので、次はそのスクリプトをどこで実行するか決めます。ローカルで都度実行しても良いのですが、今回はGitHub ActionsとAzure AppService(Functions)を候補に調査しました。
ここで問題となるのが先ほど作成したスクリプトはGitHub GraphQL APIを使用してOrganizationの設定を操作していることです。それがたとえOrganization内で設定したGitHub Actionsであっても規制対象となります。
そのため、GitHub Actionsを使用する場合はセルフホステッドランナーを構築しIPアドレスを固定することが推奨されています。GitHubでもAzureと同様に週次更新されるIPアドレス範囲の中から選ばれるのですが、GitHub Actionsで使用するIPアドレス範囲は2000件以上あるため現実的ではありません。
Azure AppServiceを使用する場合は20件程度のIPアドレス範囲を追加で登録するか、NAT Gatewayを使用してIPアドレスを固定します。今回は準備も少なくコストもかからないAzure AppServiceをNAT Gatewayなしで実施することにしました。Azure東日本リージョンに構築したので、スクリプトのget_azure_ip_list関数のservicetagに"AppService.JapanEast"を追加してください。
作成した更新スクリプトが毎日1回動作するようタイマートリガーを設定し、一度手動で実行します。正常に動作すると許可IPアドレスリストにAzureで使用するIPアドレスが登録されるので、許可IPアドレス機能を有効にして準備完了。後は数日モニタリングし、正常に許可IPアドレスリストが更新されていくのを確認して検証を終えました。
その他考慮事項
API認証
説明を省きましたがGitHub GraphQL APIの利用には認証が必要です。許可IPアドレスリストの管理という性質上、運用管理機能として考えていたため、GitHub Appsを使用して個人に紐づかない方法で認証を行うことを検討していました。しかし、今回利用したAPIではGitHub Appsによる認証を行えなかったため個人の認証トークンを使用するしかありませんでした。
また、認証トークンなどの秘匿情報の管理も必要です。今回はアプリケーションの設定を利用しましたがAzure Key Vaultを使用したり、GitHub Actionsを利用する場合ではGitHubのSecretsを利用するようにしましょう。
メンテナンス
更新スクリプトを修正した際、どのように動作環境に反映させるかも検討が必要です。Azure AppServiceを利用する際はデプロイセンターを利用するのが手軽で良いかと思います。
GitHub Actionsを利用する場合、更新スクリプトの最新化については特に検討不要ですが、別途セルフホステッドランナーの動作環境のメンテナンスが必要です。
おわりに
既にGitHub Actionsなどの他のサービスと連携しているなど、許可IPアドレスを導入することで既存システムへ影響を及ぼす場合や、今後の機能導入に制限が加わる場合がある反面、多くのIPアドレスからの接続を許可する必要となる場合があることから、導入するメリットとデメリットを天秤にかけ導入の検討をしてください。
GitHubのサポートへの問い合わせも行いながら調査検討したのですが、回答していただけるだけでなく、改善要望としてGitHub側でも開発チームに引き継ぎされるケースもあるそうです。現在パブリックベータとなっているGitHub Ancionsのmanaged runnersもその一つです。
より便利に利用するためにもサポートを利用していきたいですね。
この記事が気に入ったらサポートをしてみませんか?