GitHub 会扫描存储库中已知的密钥格式,以防止对意外提交的凭据进行欺诈性使用。密钥扫描默认在公共存储库和公共 npm 包上进行。存储库管理员和组织所有者还可以启用私有存储库上的密钥扫描。作为服务提供商,您可以与 GitHub 合作,以便将您的密钥格式包含在我们的密钥扫描中。
当在公共源中找到与您的密钥格式匹配的内容时,有效负载将发送到您选择的 HTTP 端点。
当在配置为进行密钥扫描的私有存储库中找到与您的密钥格式匹配的内容时,存储库管理员和提交者会收到警报,并且可以在 GitHub 上查看和管理密钥扫描结果。有关更多信息,请参阅“管理密钥扫描警报”。
本文档介绍了您如何作为服务提供商与 GitHub 合作并加入密钥扫描合作伙伴计划。
密钥扫描流程
下图总结了公共存储库的密钥扫描流程,任何匹配项都将发送到服务提供商的验证端点。类似的流程会将 npm 注册表中公开的包中暴露的服务提供商令牌发送出去。
加入 GitHub 上的密钥扫描计划
- 联系 GitHub 以开始此流程。
- 确定您要扫描的相关密钥,并创建正则表达式以捕获它们。有关更详细的信息和建议,请参阅下面“识别您的密钥并创建正则表达式”。
- 对于公开找到的密钥匹配项,请创建一个密钥警报服务,该服务接受包含密钥扫描消息有效负载的 GitHub Webhook。
- 在您的密钥警报服务中实现签名验证。
- 在您的密钥警报服务中实现密钥撤销和用户通知。
- 提供误报反馈(可选)。
联系 GitHub 以开始此流程
要开始注册流程,请发送电子邮件至 [email protected]。
您将收到有关密钥扫描计划的详细信息,并且您需要在继续之前同意 GitHub 的参与条款。
识别您的密钥并创建正则表达式
要扫描您的密钥,GitHub 需要您要包含在密钥扫描计划中的每个密钥的以下信息
-
密钥类型的唯一、人类可读的名称。我们将使用此名称稍后在消息有效负载中生成
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"
}
]
消息正文是一个 JSON 数组,包含一个或多个对象,每个对象表示一个密钥匹配项。您的端点应该能够处理具有大量匹配项的请求,而不会超时。每个密钥匹配项的键为
- token:密钥匹配项的值。
- type:您提供的用于识别正则表达式的唯一名称。
- url:找到匹配项的公共 URL(可能为空)
- source:在 GitHub 上找到令牌的位置。
source
的有效值列表为
- Content
- 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
- 未知
在您的密钥泄露警报服务中实现签名验证
发送到您服务的 HTTP 请求也将包含标头,我们强烈建议您使用这些标头来验证您收到的消息是否真正来自 GitHub 且并非恶意。
需要查找的两个 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
的值确定要使用哪个公钥。
注意
当您向上述公钥端点发送请求时,可能会遇到速率限制。为了避免遇到速率限制,您可以使用个人访问令牌(经典)(不需要作用域)或细粒度个人访问令牌(仅需要自动公共存储库读取访问权限),如下面的示例所示,或使用条件请求。有关更多信息,请参阅“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/"}]
以下代码片段演示了如何执行签名验证。代码示例假设您已设置名为 GITHUB_PRODUCTION_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");
}
};
在您的密钥泄露警报服务中实现密钥撤销和用户通知
对于公开发现的密钥扫描,您可以增强您的密钥泄露警报服务以撤销公开的密钥并通知受影响的用户。如何在您的密钥泄露警报服务中实现这一点取决于您,但我们建议将 GitHub 向您发送消息的任何密钥视为公开且已泄露。
提供误报反馈
我们收集合作伙伴响应中检测到的单个密钥有效性的反馈。如果您希望参与,请发送电子邮件至 [email protected]。
当我们向您报告密钥时,我们会发送一个 JSON 数组,其中每个元素包含令牌、类型标识符和提交 URL。当您向我们发送反馈时,您会向我们发送有关检测到的令牌是真实凭据还是错误凭据的信息。我们接受以下格式的反馈。
您可以向我们发送原始令牌
[
{
"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”),而不是两者都发送。
- 对于原始令牌的哈希形式,您只能使用 SHA-256 对令牌进行哈希,不能使用任何其他哈希算法。
- 标签指示令牌是真实凭据(“true_positive”)还是误报(“false_positive”)。仅允许这两个小写字面字符串。
注意
对于提供误报数据的合作伙伴,我们的请求超时设置为更长(即 30 秒)。如果您需要超过 30 秒的超时,请发送电子邮件至 [email protected]。