* 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.
265 lines
8.9 KiB
Go
265 lines
8.9 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
regexp "github.com/wasilibs/go-re2"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
|
|
)
|
|
|
|
var (
|
|
validPattern = `[{
|
|
"_id": "1a8d0cca-e1a9-4318-bc2f-f5658ab2dcb5",
|
|
"name": "Github",
|
|
"type": "Detector",
|
|
"api": true,
|
|
"authentication_type": "",
|
|
"verification_url": "https://api.example.com/example",
|
|
"test_secrets": {
|
|
"github_secret": "ghs_RWGUZ6kS8_Ut7PbtR72k2miJwwYtxkpe8mOpT8feAWYZcwz43PxBVGCNATnycaQV9VUlPJe1uST5Xen7d3uZ5lilVlEVvT9AbxnhURdT3OzPtCvXydIrvE4LrDO"
|
|
},
|
|
"expected_response": "200",
|
|
"method": "GET",
|
|
"deprecated": false
|
|
}]`
|
|
secret = "ghs_RWGUZ6kS8_Ut7PbtR72k2miJwwYtxkpe8mOpT8feAWYZcwz43PxBVGCNATnycaQV9VUlPJe1uST5Xen7d3uZ5lilVlEVvT9AbxnhURdT3OzPtCvXydIrvE4LrDO"
|
|
)
|
|
|
|
func TestGithub_Pattern(t *testing.T) {
|
|
d := Scanner{}
|
|
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "valid pattern",
|
|
input: validPattern,
|
|
want: []string{secret},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
|
|
if len(matchedDetectors) == 0 {
|
|
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
|
|
return
|
|
}
|
|
|
|
results, err := d.FromData(context.Background(), false, []byte(test.input))
|
|
if err != nil {
|
|
t.Errorf("error = %v", err)
|
|
return
|
|
}
|
|
|
|
if len(results) != len(test.want) {
|
|
if len(results) == 0 {
|
|
t.Errorf("did not receive result")
|
|
} else {
|
|
t.Errorf("expected %d results, only received %d", len(test.want), len(results))
|
|
}
|
|
return
|
|
}
|
|
|
|
actual := make(map[string]struct{}, len(results))
|
|
for _, r := range results {
|
|
if len(r.RawV2) > 0 {
|
|
actual[string(r.RawV2)] = struct{}{}
|
|
} else {
|
|
actual[string(r.Raw)] = struct{}{}
|
|
}
|
|
}
|
|
expected := make(map[string]struct{}, len(test.want))
|
|
for _, v := range test.want {
|
|
expected[v] = struct{}{}
|
|
}
|
|
|
|
if diff := cmp.Diff(expected, actual); diff != "" {
|
|
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGithubTokenType(t *testing.T) {
|
|
tests := []struct {
|
|
prefix string
|
|
token string
|
|
want string
|
|
}{
|
|
{"ghp_", "ghp_" + strings.Repeat("a", 36), "Personal Access Token (classic)"},
|
|
{"github_pat_", "github_pat_" + strings.Repeat("a", 36), "Personal Access Token (fine-grained)"},
|
|
{"gho_", "gho_" + strings.Repeat("a", 36), "OAuth Access Token"},
|
|
{"ghu_", "ghu_" + strings.Repeat("a", 36), "GitHub App User-to-Server Token"},
|
|
{"ghs_", "ghs_" + strings.Repeat("a", 36), "GitHub App Server-to-Server (installation) Token"},
|
|
{"ghr_", "ghr_" + strings.Repeat("a", 36), "GitHub App Refresh Token"},
|
|
{"unknown", "not_a_github_token", "Unknown GitHub token"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.prefix, func(t *testing.T) {
|
|
got := githubTokenType(test.token)
|
|
if got != test.want {
|
|
t.Errorf("githubTokenType(%q) = %q, want %q", test.token, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// prefixAlternationPat pulls the prefix alternation out of keyPat's source,
|
|
// e.g. `(?:ghp|gho|ghu|ghs|ghr|github_pat)_` -> `ghp|gho|ghu|ghs|ghr|github_pat`.
|
|
var prefixAlternationPat = regexp.MustCompile(`\(\?:([a-zA-Z0-9_|]+)\)_`)
|
|
|
|
// keyPatPrefixes derives the token prefixes directly from keyPat's own source
|
|
// rather than from a hand-maintained list. If the two disagree, the regex is
|
|
// the authority — it is what actually decides whether a token is matched at
|
|
// all.
|
|
func keyPatPrefixes(t *testing.T) []string {
|
|
t.Helper()
|
|
|
|
m := prefixAlternationPat.FindStringSubmatch(keyPat.String())
|
|
if m == nil {
|
|
t.Fatalf("could not find a prefix alternation group in keyPat: %s\n"+
|
|
"keyPat's shape changed; update prefixAlternationPat to match, or this "+
|
|
"drift guard is silently testing nothing.", keyPat.String())
|
|
}
|
|
|
|
var prefixes []string
|
|
for _, alt := range strings.Split(m[1], "|") {
|
|
prefixes = append(prefixes, alt+"_")
|
|
}
|
|
return prefixes
|
|
}
|
|
|
|
// TestGithubTokenType_MappingMatchesKeyPatExactly is the drift guard: it
|
|
// derives the prefix set from keyPat itself rather than a hand-maintained
|
|
// list, so a seventh prefix added to keyPat and forgotten in the map cannot
|
|
// slip past silently — the regex and the map cannot diverge without a
|
|
// failure.
|
|
//
|
|
// This matters more than a normal mapping test: an unmapped prefix does not
|
|
// crash, it silently reports "Unknown GitHub token", which puts a responder
|
|
// back in the position this detector change exists to fix — holding a real
|
|
// leaked credential with no idea how to revoke it.
|
|
func TestGithubTokenType_MappingMatchesKeyPatExactly(t *testing.T) {
|
|
prefixes := keyPatPrefixes(t)
|
|
|
|
if len(prefixes) == 0 {
|
|
t.Fatal("derived zero prefixes from keyPat; the guard is not testing anything")
|
|
}
|
|
|
|
fromKeyPat := make(map[string]bool, len(prefixes))
|
|
for _, p := range prefixes {
|
|
fromKeyPat[p] = true
|
|
if _, ok := tokenTypesByPrefix[p]; !ok {
|
|
t.Errorf("keyPat matches prefix %q but tokenTypesByPrefix has no entry for it; "+
|
|
"a leaked token with this prefix would be reported as %q",
|
|
p, "Unknown GitHub token")
|
|
}
|
|
}
|
|
|
|
// The reverse direction: a mapping entry for a prefix keyPat cannot match is
|
|
// dead weight, and usually means a prefix was renamed in one place only.
|
|
for p := range tokenTypesByPrefix {
|
|
if !fromKeyPat[p] {
|
|
t.Errorf("tokenTypesByPrefix has an entry for %q, but keyPat never matches that prefix", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertNoBlankFields checks that v is not a blank string, or — if v is a
|
|
// struct — that none of its exported fields are blank strings. It exists so
|
|
// that if a row's value type grows a second field (tokenTypesByPrefix held a
|
|
// {TokenType, Remediation} struct before remediation was removed, and could
|
|
// again), row validation below catches an unset field automatically instead
|
|
// of depending on someone remembering to add a field-specific check.
|
|
func assertNoBlankFields(t *testing.T, label string, v reflect.Value) {
|
|
t.Helper()
|
|
|
|
switch v.Kind() {
|
|
case reflect.String:
|
|
if strings.TrimSpace(v.String()) == "" {
|
|
t.Errorf("%s: blank value", label)
|
|
}
|
|
case reflect.Struct:
|
|
vt := v.Type()
|
|
for i := 0; i < v.NumField(); i++ {
|
|
field := vt.Field(i)
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
assertNoBlankFields(t, label+"."+field.Name, v.Field(i))
|
|
}
|
|
default:
|
|
t.Fatalf("%s: assertNoBlankFields does not know how to validate kind %s; extend the helper for the new row type", label, v.Kind())
|
|
}
|
|
}
|
|
|
|
// TestTokenTypesByPrefix_RowsAreWellFormed checks each row in
|
|
// tokenTypesByPrefix in isolation, independent of keyPat. The tests above
|
|
// confirm the map's keys line up with keyPat; none of them would catch a
|
|
// malformed row added alongside a correct key — e.g. a copy-pasted value
|
|
// that leaves two different prefixes describing the same token type, or a
|
|
// field left blank.
|
|
func TestTokenTypesByPrefix_RowsAreWellFormed(t *testing.T) {
|
|
const unknown = "Unknown GitHub token"
|
|
|
|
seenValues := make(map[string]string, len(tokenTypesByPrefix)) // value -> first prefix that used it
|
|
|
|
for prefix, tokenType := range tokenTypesByPrefix {
|
|
if prefix == "" {
|
|
t.Error("tokenTypesByPrefix has an empty-string prefix key")
|
|
}
|
|
if !strings.HasSuffix(prefix, "_") {
|
|
t.Errorf("prefix %q does not end in \"_\"; githubTokenType matches by strings.HasPrefix, so a bare prefix can match unrelated tokens", prefix)
|
|
}
|
|
assertNoBlankFields(t, prefix, reflect.ValueOf(tokenType))
|
|
if tokenType == unknown {
|
|
t.Errorf("prefix %q maps to the reserved fallback value %q; a real row must not collide with it", prefix, unknown)
|
|
}
|
|
if other, dup := seenValues[tokenType]; dup {
|
|
t.Errorf("prefixes %q and %q both map to token type %q; each prefix must describe a distinct credential", prefix, other, tokenType)
|
|
} else {
|
|
seenValues[tokenType] = prefix
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGithubTokenType_EveryKeyPatPrefixResolves closes the loop end to end:
|
|
// build a token for each prefix keyPat actually accepts, confirm keyPat
|
|
// matches it, and confirm githubTokenType resolves it to something other than
|
|
// the unknown fallback. The two tests above check the data structures agree;
|
|
// this one checks the behaviour they produce is correct for every real input.
|
|
func TestGithubTokenType_EveryKeyPatPrefixResolves(t *testing.T) {
|
|
const unknown = "Unknown GitHub token"
|
|
|
|
for _, prefix := range keyPatPrefixes(t) {
|
|
t.Run(prefix, func(t *testing.T) {
|
|
token := prefix + strings.Repeat("a", 36)
|
|
|
|
if !keyPat.MatchString(token) {
|
|
t.Fatalf("keyPat does not match %q built from its own prefix alternation; "+
|
|
"the derived prefix is wrong or keyPat's body changed", token)
|
|
}
|
|
|
|
got := githubTokenType(token)
|
|
if got == unknown {
|
|
t.Errorf("githubTokenType(%q) = %q; every prefix keyPat matches must resolve to a real type", token, got)
|
|
}
|
|
if got == "" {
|
|
t.Errorf("githubTokenType(%q) returned an empty type", token)
|
|
}
|
|
})
|
|
}
|
|
}
|