Skip to main content

密码扫描合作伙伴计划

作为服务提供者,您可以与 GitHub 合作,通过密码扫描保护您的密码令牌格式,该扫描将搜索意外提交的密码格式,并且可以发送到服务提供者的验证端点。

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 计划

  1. 联系 GitHub 以启动流程。
  2. 识别要扫描的相关密码,并创建正则表达式来捕获它们。
  3. 对于公开发现的密码匹配项,创建一个密码警报服务,以便从 GitHub 接受包含 secret scanning 消息有效负载的 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 消息。

示例请求正文

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

消息正文是一个 JSON 数组,其中包含一个或多个对象,每个对象表示一个机密匹配项。 你的终结点应该能够在不超时的情况下处理包含大量匹配项的请求。每个机密匹配项的密钥为:

  • 令牌:机密匹配项的值。
  • 类型:提供用于标识正则表达式的唯一名称。
  • url:匹配项所处的公共 URL(可能为空)
  • 源:在 GitHub 上找到令牌的位置。

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
  • 未知

在密码警报服务中实施签名验证

向服务发出的 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 的值确定使用哪个公钥。

注意:向上述公钥终结点发送请求时,可能会达到速率限制。 若要避免达到速率限制,可以使用 personal access token (classic)(不需要作用域)或 fine-grained personal access token(只需要自动公共存储库读取访问权限),如以下示例所示,或使用条件请求。 有关详细信息,请参阅“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向您发送的公开和泄露示警消息所涉及的任何密码。

提供误报的反馈

我们在合作伙伴响应中收集有关检测到的各个密码有效性的反馈。 如果你想参加,请发送电子邮件至 secret-scanning@github.com

向您报告密码时,我们会发送一个 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") 或哈希形式,而不要同时发送这两种形式。
  • 对于原始令牌的哈希形式,您只能使用 SHA-256 对令牌进行哈希处理,而不能使用任何其他哈希算法。
  • 用标签指示令牌为实报 ("true_positive") 还是误报 ("false_positive")。 只允许使用这两个小写的文字字符串。

注意:对于提供误报数据的合作伙伴,我们的请求超时设置得更高(即 30 秒)。 如果需要超过 30 秒的超时时间,请发送电子邮件至 secret-scanning@github.com