GitHubは、既知のシークレットフォーマットに対してリポジトリをスキャンし、誤ってコミットされたクレデンシャルが不正利用されることを防ぎます。 Secret scanning は、パブリック リポジトリとパブリック npm パッケージで既定で実行されます。 リポジトリ管理者と組織の所有者は、プライベート リポジトリで secret scanning を有効にすることもできます。 サービスプロバイダーはGitHubと連携し、シークレットのフォーマットがsecret scanningに含まれるようにすることができます。
シークレット形式の一致がパブリック ソースで見つかった場合、選択した HTTP エンドポイントにペイロードが送信されます。
secret scanningが設定されたプライベートリポジトリでシークレットフォーマットへの一致が見つかった場合、リポジトリの管理者とコミッターにアラートが発せられ、GitHub上でsecret scanningの結果を見て管理できます。 詳しくは、「シークレット スキャンからのアラートの管理」を参照してください。
この記事では、サービスプロバイダーとしてGitHubとパートナーになり、secret scanningパートナープログラムに参加する方法を説明します。
secret scanningのプロセス
以下の図は、パブリックリポジトリに対するsecret scanningのプロセスをまとめたもので、一致があった場合にサービスプロバイダーへの検証エンドポイントに送信されています。 同様のプロセスにより、npm レジストリのパブリック パッケージで公開されているサービス プロバイダーのトークンが送信されます。
GitHubのsecret scanningプログラムへの参加
- プロセスを開始するために、GitHubに連絡してください。
- スキャンしたい関連シークレットを特定し、それらを捕捉するための正規表現を作成してください。 詳細と推奨事項については、以下の「シークレットを識別して正規表現を作成する」を参照してください。
- パブリックで見つかったシークレットの一致に対応するために、secret scanning のメッセージ ペイロードを含む GitHub からの Webhook を受け付けるシークレット アラート サービスを作成してください。
- シークレットアラートサービスに、署名検証を実装してください。
- シークレットアラートサービスに、シークレットの破棄とユーザーへの通知を実装してください。
- 誤検知に対するフィードバックを行います (任意)。
プロセスを開始するためのGitHubへの連絡
登録プロセスを開始するには、[email protected] にメールしてください。
secret scanningプログラムの詳細が送信されます。手続きを進めるには、GitHubの参加規約に同意する必要があります。
シークレットの特定と正規表現の作成
シークレットをスキャンするには、GitHubはsecret scanningに含める各シークレットについて以下の情報が必要です。
-
シークレットの種類に対する、ユニークで人が読める名前。 これを使用して、後でメッセージ ペイロードに
Type
値を生成します。 -
このシークレットの種類を見つける正規表現。 できるかぎり正確にすることをお勧めします。そうすることで、誤検知の数を減らすことができます。 高品質で識別可能なシークレットのベスト プラクティスは次のとおりです。
- 一意に定義されたプレフィックス
- エントロピの高いランダム文字列
- 32 ビットのチェックサム
-
サービスのテスト アカウント。 これにより、シークレットの例を生成して分析し、誤検知をさらに減らすことができます。
-
GitHubからのメッセージを受信するエンドポイントのURL。 この URL は各シークレットの種類ごとにユニークである必要はありません。
この情報を [email protected] に送信します。
シークレットアラートサービスの作成
提供したURLに、パブリックでインターネットからアクセスできるHTTPエンドポイントを作成してください。 パブリックで正規表現の一致が見つかると、GitHub によって HTTP POST
メッセージがエンドポイントに送信されます。
要求本文の例
[
{
"token":"NMIfyYncKcRALEXAMPLE",
"type":"mycompany_api_token",
"url":"https://github.com/octocat/Hello-World/blob/12345600b9cbe38a219f39a9941c9319b600c002/foo/bar.txt",
"source":"content"
}
]
メッセージ本文は、1 つ以上のオブジェクトが含まれる JSON 配列であり、各オブジェクトは 1 つのシークレットの一致を表しています。 エンドポイントには、タイムアウトすることなく大量の一致を含むリクエストを処理する能力が必要とされます。シークレットの一致には、それぞれ次のキーがあります。
- token: シークレットが一致する値。
- type: 正規表現を識別するために指定した一意の名前。
- url: 一致が見つかったパブリック URL (空の場合があります)
- source: GitHub 上で token が見つかった場所。
source
の有効値の一覧は次のとおりです。
- コンテンツ
- Commit
- Pull_request_title
- Pull_request_description
- Pull_request_comment
- Issue_title
- Issue_description
- Issue_comment
- Discussion_title
- Discussion_body
- Discussion_comment
- Commit_comment
- Gist_content
- Gist_comment
- Npm
- Unknown
シークレットアラートサービスへの署名検証の実装
サービスへの HTTP 要求にも、メッセージが間違いなく GitHub から届いたものであり、悪意のあるものではないことを検証するために使用を強くお勧めしているヘッダーが含まれる場合があります。
確認するのは次の 2 つの HTTP ヘッダーです。
Github-Public-Key-Identifier
: API からどのkey_identifier
を使うかGithub-Public-Key-Signature
: ペイロードの署名
https://api.github.com/meta/public_keys/secret_scanning から GitHub シークレット スキャン公開キーを取得し、ECDSA-NIST-P256V1-SHA256
アルゴリズムを使用してメッセージを検証できます。 エンドポイントによって、いくつかの key_identifier
と公開キーが指定されます。 Github-Public-Key-Identifier
の値に応じてどの公開キーを使うかを決めます。
注: 上記の公開キー エンドポイントに要求を送信すると、レート制限に達する可能性があります。 レート制限を回避するには、以下のサンプルで示すように personal access token (classic) (no scopes required) または fine-grained personal access token (only the automatic public repositories read access required) を使うか、条件付き要求を使います。 詳しくは、「REST API を使用した作業の開始」を参照してください。
注: この署名は、生のメッセージ本文を使用して生成されました。 そのため、署名の検証にもJSONの文字列を解析して変換するのではなく、生のメッセージ本文を利用することが重要です。これは、メッセージの並べ替えやスペースの変更を避けるためです。
エンドポイントを検証するために送信されたサンプル HTTP POST
POST / HTTP/2
Host: HOST
Accept: */*
Content-Length: 104
Content-Type: application/json
Github-Public-Key-Identifier: bcb53661c06b4728e59d897fb6165d5c9cda0fd9cdf9d09ead458168deb7518c
Github-Public-Key-Signature: MEQCIQDaMKqrGnE27S0kgMrEK0eYBmyG0LeZismAEz/BgZyt7AIfXt9fErtRS4XaeSt/AO1RtBY66YcAdjxji410VQV4xg==
[{"source":"commit","token":"some_token","type":"some_type","url":"https://example.com/base-repo-url/"}]
次のコード スニペットは、署名の検証をどのように行うかについて示しています。
このコード サンプルは、レート制限に達しないように生成された personal access token を含む GITHUB_PRODUCTION_TOKEN
という環境変数を設定していることを前提としています。 personal access token には、スコープやアクセル許可が必要ありません。
Go での検証サンプル
package main
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/http"
"os"
)
func main() {
payload := `[{"token":"some_token","type":"some_type","url":"some_url","source":"some_source"}]`
kID := "f9525bf080f75b3506ca1ead061add62b8633a346606dc5fe544e29231c6ee0d"
kSig := "MEUCIFLZzeK++IhS+y276SRk2Pe5LfDrfvTXu6iwKKcFGCrvAiEAhHN2kDOhy2I6eGkOFmxNkOJ+L2y8oQ9A2T9GGJo6WJY="
// Fetch the list of GitHub Public Keys
req, err := http.NewRequest("GET", "https://api.github.com/meta/public_keys/secret_scanning", nil)
if err != nil {
fmt.Printf("Error preparing request: %s\n", err)
os.Exit(1)
}
if len(os.Getenv("GITHUB_PRODUCTION_TOKEN")) == 0 {
fmt.Println("Need to define environment variable GITHUB_PRODUCTION_TOKEN")
os.Exit(1)
}
req.Header.Add("Authorization", "Bearer "+os.Getenv("GITHUB_PRODUCTION_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("Error requesting GitHub signing keys: %s\n", err)
os.Exit(2)
}
decoder := json.NewDecoder(resp.Body)
var keys GitHubSigningKeys
if err := decoder.Decode(&keys); err != nil {
fmt.Printf("Error decoding GitHub signing key request: %s\n", err)
os.Exit(3)
}
// Find the Key used to sign our webhook
pubKey, err := func() (string, error) {
for _, v := range keys.PublicKeys {
if v.KeyIdentifier == kID {
return v.Key, nil
}
}
return "", errors.New("specified key was not found in GitHub key list")
}()
if err != nil {
fmt.Printf("Error finding GitHub signing key: %s\n", err)
os.Exit(4)
}
// Decode the Public Key
block, _ := pem.Decode([]byte(pubKey))
if block == nil {
fmt.Println("Error parsing PEM block with GitHub public key")
os.Exit(5)
}
// Create our ECDSA Public Key
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
fmt.Printf("Error parsing DER encoded public key: %s\n", err)
os.Exit(6)
}
// Because of documentation, we know it's a *ecdsa.PublicKey
ecdsaKey, ok := key.(*ecdsa.PublicKey)
if !ok {
fmt.Println("GitHub key was not ECDSA, what are they doing?!")
os.Exit(7)
}
// Parse the Webhook Signature
parsedSig := asn1Signature{}
asnSig, err := base64.StdEncoding.DecodeString(kSig)
if err != nil {
fmt.Printf("unable to base64 decode signature: %s\n", err)
os.Exit(8)
}
rest, err := asn1.Unmarshal(asnSig, &parsedSig)
if err != nil || len(rest) != 0 {
fmt.Printf("Error unmarshalling asn.1 signature: %s\n", err)
os.Exit(9)
}
// Verify the SHA256 encoded payload against the signature with GitHub's Key
digest := sha256.Sum256([]byte(payload))
keyOk := ecdsa.Verify(ecdsaKey, digest[:], parsedSig.R, parsedSig.S)
if keyOk {
fmt.Println("THE PAYLOAD IS GOOD!!")
} else {
fmt.Println("the payload is invalid :(")
os.Exit(10)
}
}
type GitHubSigningKeys struct {
PublicKeys []struct {
KeyIdentifier string `json:"key_identifier"`
Key string `json:"key"`
IsCurrent bool `json:"is_current"`
} `json:"public_keys"`
}
// asn1Signature is a struct for ASN.1 serializing/parsing signatures.
type asn1Signature struct {
R *big.Int
S *big.Int
}
Ruby での検証サンプル
require 'openssl'
require 'net/http'
require 'uri'
require 'json'
require 'base64'
payload = <<-EOL
[{"token":"some_token","type":"some_type","url":"some_url","source":"some_source"}]
EOL
payload = payload
signature = "MEUCIFLZzeK++IhS+y276SRk2Pe5LfDrfvTXu6iwKKcFGCrvAiEAhHN2kDOhy2I6eGkOFmxNkOJ+L2y8oQ9A2T9GGJo6WJY="
key_id = "f9525bf080f75b3506ca1ead061add62b8633a346606dc5fe544e29231c6ee0d"
url = URI.parse('https://api.github.com/meta/public_keys/secret_scanning')
raise "Need to define GITHUB_PRODUCTION_TOKEN environment variable" unless ENV['GITHUB_PRODUCTION_TOKEN']
request = Net::HTTP::Get.new(url.path)
request['Authorization'] = "Bearer #{ENV['GITHUB_PRODUCTION_TOKEN']}"
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = (url.scheme == "https")
response = http.request(request)
parsed_response = JSON.parse(response.body)
current_key_object = parsed_response["public_keys"].find { |key| key["key_identifier"] == key_id }
current_key = current_key_object["key"]
openssl_key = OpenSSL::PKey::EC.new(current_key)
puts openssl_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), payload.chomp)
JavaScript での検証サンプル
const crypto = require("crypto");
const axios = require("axios");
const GITHUB_KEYS_URI = "https://api.github.com/meta/public_keys/secret_scanning";
/**
* Verify a payload and signature against a public key
* @param {String} payload the value to verify
* @param {String} signature the expected value
* @param {String} keyID the id of the key used to generated the signature
* @return {void} throws if the signature is invalid
*/
const verify_signature = async (payload, signature, keyID) => {
if (typeof payload !== "string" || payload.length === 0) {
throw new Error("Invalid payload");
}
if (typeof signature !== "string" || signature.length === 0) {
throw new Error("Invalid signature");
}
if (typeof keyID !== "string" || keyID.length === 0) {
throw new Error("Invalid keyID");
}
const keys = (await axios.get(GITHUB_KEYS_URI)).data;
if (!(keys?.public_keys instanceof Array) || keys.length === 0) {
throw new Error("No public keys found");
}
const publicKey = keys.public_keys.find((k) => k.key_identifier === keyID) ?? null;
if (publicKey === null) {
throw new Error("No public key found matching key identifier");
}
const verify = crypto.createVerify("SHA256").update(payload);
if (!verify.verify(publicKey.key, Buffer.from(signature, "base64"), "base64")) {
throw new Error("Signature does not match payload");
}
};
ユーザーシークレットアラートサービスへのシークレットの破棄とユーザ通知の実装
パブリックで見つかった secret scanning に対しては、シークレット アラート サービスを拡張して公開されたシークレットを取り消し、影響を受けるユーザーに通知することができます。 これをシークレットアラートサービスへどのように実装するかは実装者に任されていますが、GitHubがメッセージを送信したすべてのシークレットは、公開され、侵害されたものと考えることをおすすめします。
誤検知に対するフィードバック
当社は、パートナーのレスポンスにおいて検出された個々のシークレットについて、妥当性のフィードバックを収集しています。 参加したい場合は、[email protected] にメールでお問い合わせください。
当社がシークレットを報告する際は、トークン、型識別子、コミットURLを含む各要素のJSON配列を送信します。 当社がフィードバックを受け取る際、あなたは検出されたトークンが正しい認証情報を持っているかいないかについての情報を送信します。 フィードバックは以下のフォーマットで受け取ります。
生のトークンは以下のように送信できます。
[
{
"token_raw": "The raw token",
"token_type": "ACompany_API_token",
"label": "true_positive"
}
]
また、SHA-256を使用して一方向暗号化ハッシュを実行した後、ハッシュ形式でトークンを提供することも可能です。
[
{
"token_hash": "The SHA-256 hashed form of the raw token",
"token_type": "ACompany_API_token",
"label": "false_positive"
}
]
重要なポイントをいくつか以下に示します。
- トークンは、生の形式 ("token_raw") またはハッシュ形式 ("token_hash") のいずれか1つだけを送信してください。両方を送信しないでください。
- 生のトークンをハッシュ化する場合、SHA-256のみを使用します。他のハッシュ化アルゴリズムは使用しないでください。
- ラベルは、トークンが真陽性 ("true_positive") か誤検知 ("false_positive") かを示します。 これら2つの、小文字のリテラル文字列のみを受け付けます。
注: 誤検知に関するデータを提供するパートナーに対しては、要求のタイムアウトは長め (30 秒) に設定されています。 30 秒を超えるタイムアウトが必要な場合は、[email protected] にメールでお問い合わせください。