Skip to main content

Secret scanningパートナープログラム

サービスプロバイダーは、GitHubとパートナーになり、シークレットスキャンニングを通じてシークレットトークンのフォーマットを保護できます。シークレットスキャンニングは、そのシークレットのフォーマットで誤って行われたコミットを検索し、サービスプロバイダーの検証用エンドポイントに送信します。

GitHubは、既知のシークレットフォーマットに対してリポジトリをスキャンし、誤ってコミットされたクレデンシャルが不正利用されることを防ぎます。 Secret scanningは、デフォルトでパブリックなリポジトリで行われ、プライベートリポジトリではリポジトリ管理者またはOrganizationのオーナーが有効化できます。 サービスプロバイダーはGitHubと連携し、シークレットのフォーマットがsecret scanningに含まれるようにすることができます。

シークレットのフォーマットに対する一致がパブリックリポジトリで見つかった場合、選択したHTTPのエンドポイントにペイロードが送信されます。

secret scanningが設定されたプライベートリポジトリでシークレットフォーマットへの一致が見つかった場合、リポジトリの管理者とコミッターにアラートが発せられ、GitHub上でsecret scanningの結果を見て管理できます。 詳細については、「secret scanning からのアラートを管理する」を参照してください。

この記事では、サービスプロバイダーとしてGitHubとパートナーになり、secret scanningパートナープログラムに参加する方法を説明します。

secret scanningのプロセス

パブリックリポジトリにおけるsecret scanningの動作

以下の図は、パブリックリポジトリに対するsecret scanningのプロセスをまとめたもので、一致があった場合にサービスプロバイダへの検証エンドポイントに送信されています。

シークレットをスキャンし、サービス プロバイダーの検証エンドポイントに一致を送信するプロセスを示すフロー図

GitHubのsecret scanningプログラムへの参加

  1. プロセスを開始するために、GitHubに連絡してください。
  2. スキャンしたい関連シークレットを特定し、それらを捕捉するための正規表現を作成してください。
  3. パブリックリポジトリで見つかったシークレットの一致に対応するために、secret scanningのメッセージペイロードを含むGitHubからのwebhookを受け付けるシークレットアラートサービスを作成してください。
  4. シークレットアラートサービスに、署名検証を実装してください。
  5. シークレットアラートサービスに、シークレットの破棄とユーザへの通知を実装してください。
  6. 誤検知に対するフィードバックを行ないます (任意)。

プロセスを開始するためのGitHubへの連絡

登録プロセスを開始するには、secret-scanning@github.com にメールしてください。

secret scanningプログラムの詳細が送信されます。手続きを進めるには、GitHubの参加規約に同意する必要があります。

シークレットの特定と正規表現の作成

シークレットをスキャンするには、GitHubはsecret scanningに含める各シークレットについて以下の情報が必要です。

  • シークレットの種類に対する、ユニークで人が読める名前。 これを使用して、後でメッセージ ペイロードに Type 値を生成します。
  • このシークレットの種類を見つける正規表現。 できるかぎり正確にしてください。そうすることで、誤検知の数を減らすことができます。
  • GitHubからのメッセージを受信するエンドポイントのURL。 これは各シークレットの種類ごとにユニークである必要はありません。

この情報を secret-scanning@github.com に送信します。

シークレットアラートサービスの作成

提供したURLに、パブリックでインターネットからアクセスできるHTTPエンドポイントを作成してください。 パブリック リポジトリで正規表現の一致が見つかると、GitHub によって HTTP POST メッセージがエンドポイントに送信されます。

エンドポイントに送信されるPOSTの例

POST / HTTP/2
Host: HOST
Accept: */*
Content-Type: application/json
GITHUB-PUBLIC-KEY-IDENTIFIER: 90a421169f0a406205f1563a953312f0be898d3c7b6c06b681aa86a874555f4a
GITHUB-PUBLIC-KEY-SIGNATURE: MEQCIA6C6L8ZYvZnqgV0zwrrmRab10QmIFV396gsba/WYm9oAiAI6Q+/jNaWqkgG5YhaWshTXbRwIgqIK6Ru7LxVYDbV5Q==
Content-Length: 0123

[{"token":"NMIfyYncKcRALEXAMPLE","type":"mycompany_api_token","url":"https://github.com/octocat/Hello-World/blob/12345600b9cbe38a219f39a9941c9319b600c002/foo/bar.txt"}]

メッセージのボディはJSONの配列で、以下の内容を持つ1つ以上のオブジェクトを含みます。 複数の一致が見つかった場合には、GitHubは複数のシークレットの一致を含む1つのメッセージを送信することがあります。 エンドポイントは、タイムアウトすることなく大量の一致を含むリクエストを処理できなければなりません。

  • Token: シークレットが一致する値。
  • Type: 正規表現を識別するために指定した一意の名前。
  • URL: 一致が見つかったパブリック コミット URL。

シークレットアラートサービスへの署名検証の実装

シークレットサービスには署名検証サービスを実装して、受信したメッセージが本当にGitHubからのものであり、悪意がないことを保証することを強くおすすめします。

https://api.github.com/meta/public_keys/secret_scanning から GitHub シークレット スキャン公開キーを取得し、ECDSA-NIST-P256V1-SHA256 アルゴリズムを使用してメッセージを検証できます。

: 上記の公開キー エンドポイントに要求を送信すると、レート制限に達する可能性があります。 レート制限を回避するには、以下のサンプルで示すように個人アクセストークン (スコープ不要) を使うか、条件付きリクエストを利用できます。 詳細については、「REST API を使用した作業の開始」をご覧ください。

次のメッセージを受信したとして、以下のコードは署名検証の方法を示しています。 このコード スニペットは、レート制限に達しないように、生成された PAT (https://github.com/settings/tokens) を使用して GITHUB_PRODUCTION_TOKEN という環境変数を設定していることを前提としています。 このPATには、スコープや権限は不要です。

: この署名は、生のメッセージ本文を使用して生成されました。 そのため、署名の検証にもJSONの文字列を解析して変換するのではなく、生のメッセージ本文を利用することが重要です。これは、メッセージの並べ替えやスペースの変更を避けるためです。

エンドポイントを検証するために送信されたサンプル メッセージ

POST / HTTP/2
Host: HOST
Accept: */*
content-type: application/json
GITHUB-PUBLIC-KEY-IDENTIFIER: 90a421169f0a406205f1563a953312f0be898d3c7b6c06b681aa86a874555f4a
GITHUB-PUBLIC-KEY-SIGNATURE: MEUCIQDKZokqnCjrRtw0tni+2Ltvl/uiMJ1EGumEsp1BsNr32AIgQY1YXD2nlj+XNfGK4rBfkMJ1JDOQcYXxa2sY8FNkrKc=
Content-Length: 0000

[{"token":"some_token","type":"some_type","url":"some_url"}]

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"}]`

  kID := "90a421169f0a406205f1563a953312f0be898d3c7b6c06b681aa86a874555f4a"

  kSig := "MEUCIQDKZokqnCjrRtw0tni+2Ltvl/uiMJ1EGumEsp1BsNr32AIgQY1YXD2nlj+XNfGK4rBfkMJ1JDOQcYXxa2sY8FNkrKc="

  // 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"}]
EOL

payload = payload

signature = "MEUCIQDKZokqnCjrRtw0tni+2Ltvl/uiMJ1EGumEsp1BsNr32AIgQY1YXD2nlj+XNfGK4rBfkMJ1JDOQcYXxa2sY8FNkrKc="

key_id = "90a421169f0a406205f1563a953312f0be898d3c7b6c06b681aa86a874555f4a"

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がメッセージを送信したすべてのシークレットは、公開され、侵害されたものと考えることをおすすめします。

誤検知に対するフィードバック

当社は、パートナーのレスポンスにおいて検出された個々のシークレットについて、妥当性のフィードバックを収集しています。 参加したい場合は、secret-scanning@github.com にメールでお問い合わせください。

当社がシークレットを報告する際は、トークン、型識別子、コミット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 秒を超えるタイムアウトが必要な場合は、secret-scanning@github.com にメールでお問い合わせください。