Files
trufflehog/pkg/detectors/github/v2/github.go
Shawn Hartsock 8ee38640f6 detectors/github/v2: differentiate token type in ExtraData (#5223)
* detectors/github/v2: differentiate token type in ExtraData

FromData matched all six GitHub token prefixes (ghp_, github_pat_,
gho_, ghu_, ghs_, ghr_) with a single regex, but every match was
tagged with the same DetectorType_Github and the same hardcoded PAT
description. The matched prefix was discarded before it could reach
any downstream consumer.

Add token_type and remediation to ExtraData, following the same
convention already used by the launchdarkly, slack, and larksuite
detectors, so consumers can tell a leaked OAuth/GitHub App token
apart from a classic or fine-grained PAT and point users at the
correct GitHub settings page to revoke it.

* detectors/github/v2: drop remediation field, add token_type test coverage

Per review feedback on #5223: remove the remediation field from ExtraData
(maintainers are expanding howtorotate.com docs instead, and want to keep
the additive ExtraData surface minimal for downstream consumers). This
also moots the ghr_ remediation-URL bug Bugbot flagged, since that field
no longer exists. Add table-driven tests for token_type mapping plus a
drift guard ensuring every keyPat prefix has a tokenTypesByPrefix entry.

* detectors/github/v2: fix integration test expectations for token_type

Bugbot caught this on the merge-into-branch commit: github_integration_test.go
does an exact ExtraData comparison via pretty.Compare and didn't account for
the new token_type field, so every case would fail under the detectors build
tag. Add the expected token_type per case.

* detectors/github/v2: derive drift guard from keyPat's own regex source

TestGithubTokenType_KeyPatPrefixesCovered compared tokenTypesByPrefix
against a second hand-maintained prefix list, so a new prefix added to
keyPat and forgotten in the map could also be forgotten in that list,
leaving the "drift guard" green with nothing to catch. Replace it with
TestGithubTokenType_MappingMatchesKeyPatExactly, which parses the prefix
alternation out of keyPat.String() directly, and
TestGithubTokenType_EveryKeyPatPrefixResolves, which builds a token per
derived prefix and confirms it resolves to a real type end to end.

* detectors/github/v2: add row-level validation for tokenTypesByPrefix

The keyPat drift guards check that map keys line up with the regex; they
say nothing about whether an individual row is well-formed. A malformed
row with a correct key (blank value, or a value copy-pasted from another
prefix) would slip through both. Add TestTokenTypesByPrefix_RowsAreWellFormed
to check each row in isolation: prefix key ends in "_", value is non-blank,
value isn't the reserved "Unknown GitHub token" fallback, and no two
prefixes share a value. Verified it fails on an injected duplicate-value
row before reverting the injection.

* detectors/github/v2: make row-blank-field check reflect-based, forward-looking

TestTokenTypesByPrefix_RowsAreWellFormed only checked the current string
value for blankness. That check is presence-only in the sense the removed
TestGithubTokenType_KeyPatPrefixesCovered was: neither would have caught a
row whose value type is a struct with a field left at its zero value (the
shape tokenTypesByPrefix had before remediation was dropped, and could have
again). Replace the direct blank check with assertNoBlankFields, which
recurses into struct fields via reflection, so a future second field on a
row is covered automatically instead of needing a matching manual check.
Verified against a probe using the pre-removal {TokenType, Remediation}
struct shape with one field left blank before writing this commit.
2026-08-24 15:59:29 -04:00

125 lines
4.0 KiB
Go

package github
import (
"context"
"fmt"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
v1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/github/v1"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
v1.Scanner
}
// Ensure the Scanner satisfies the interfaces at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.Versioner = (*Scanner)(nil)
var _ detectors.EndpointCustomizer = (*Scanner)(nil)
var _ detectors.CloudProvider = (*Scanner)(nil)
func (s Scanner) Version() int {
return 2
}
func (Scanner) CloudEndpoint() string { return "https://api.github.com" }
var (
// Oauth token
// https://developer.github.com/v3/#oauth2-token-sent-in-a-header
// Token type list:
// https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
// https://github.blog/changelog/2022-10-18-introducing-fine-grained-personal-access-tokens/
keyPat = regexp.MustCompile(`\b((?:ghp|gho|ghu|ghs|ghr|github_pat)_[a-zA-Z0-9_]{36,255})\b`)
// TODO: Oauth2 client_id and client_secret
// https://developer.github.com/v3/#oauth2-keysecret
// tokenTypesByPrefix maps each GitHub token prefix to a human-readable type.
// Each prefix identifies a materially different credential (PAT, OAuth
// grant, or GitHub App token) that is revoked/rotated through a different
// GitHub settings page.
// https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
tokenTypesByPrefix = map[string]string{
"ghp_": "Personal Access Token (classic)",
"github_pat_": "Personal Access Token (fine-grained)",
"gho_": "OAuth Access Token",
"ghu_": "GitHub App User-to-Server Token",
"ghs_": "GitHub App Server-to-Server (installation) Token",
"ghr_": "GitHub App Refresh Token",
}
)
// githubTokenType returns the human-readable token type for a matched token,
// keyed off its prefix. Falls back to a generic label if no known prefix
// matches (should not happen given keyPat, but keeps this safe).
func githubTokenType(token string) string {
for prefix, tokenType := range tokenTypesByPrefix {
if strings.HasPrefix(token, prefix) {
return tokenType
}
}
return "Unknown GitHub token"
}
// Keywords are used for efficiently pre-filtering chunks.
// Use identifiers in the secret preferably, or the provider name.
func (s Scanner) Keywords() []string {
return []string{"ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"}
}
// FromData will find and optionally verify GitHub secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)
matches := keyPat.FindAllStringSubmatch(dataStr, -1)
for _, match := range matches {
// First match is entire regex, second is the first group.
token := match[1]
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_Github,
Raw: []byte(token),
ExtraData: map[string]string{
"rotation_guide": "https://howtorotate.com/docs/tutorials/github/",
"version": fmt.Sprintf("%d", s.Version()),
"token_type": githubTokenType(token),
},
SecretParts: map[string]string{"key": token},
}
if verify {
client := common.SaneHttpClient()
isVerified, userResponse, headers, err := s.VerifyGithub(ctx, client, token)
s1.Verified = isVerified
s1.SetVerificationError(err, token)
if userResponse != nil {
v1.SetUserResponse(userResponse, &s1)
}
if headers != nil {
v1.SetHeaderInfo(headers, &s1)
}
}
results = append(results, s1)
}
return
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_Github
}
func (s Scanner) Description() string {
return "GitHub is a platform for version control and collaboration. Personal access tokens (PATs) can be used to access and modify repositories and other resources."
}