[INS-497] Add Pganalyze Read Key Detector (#4993)

* Add pganalyze read key detector

* regen protos and ran gofmt
This commit is contained in:
Muneeb Ullah Khan
2026-06-29 11:36:40 +05:00
committed by GitHub
parent c1a1d6a28a
commit c09d72666a
9 changed files with 537 additions and 27 deletions
+1
View File
@@ -543,6 +543,7 @@ func run(state overseer.State, logSync func() error) {
feature.RevDetectorEnabled.Store(true)
feature.UserDetectorEnabled.Store(true)
feature.BraintrustDetectorEnabled.Store(true)
feature.PgAnalyzeReadKeyDetectorEnabled.Store(true)
conf := &config.Config{}
if *configFilename != "" {
@@ -0,0 +1,144 @@
package pganalyzereadkey
import (
"context"
"fmt"
"io"
"net/http"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
client *http.Client
}
// Compile-time interface check
var _ detectors.Detector = (*Scanner)(nil)
var (
defaultClient = common.SaneHttpClient()
// pganalyze Read API keys use the format:
// pgar_<27 alphanumeric characters>
//
// Example:
// pgar_abcdefghijklmnopqrstuvwxyz12
pganalyzeTokenPat = regexp.MustCompile(
`\b(pgar_[A-Za-z0-9]{27})\b`,
)
)
// Keywords used for fast pre-filtering
func (s Scanner) Keywords() []string {
return []string{
"pgar_",
}
}
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}
// FromData scans for pganalyze API tokens and optionally verifies them.
func (s Scanner) FromData(
ctx context.Context,
verify bool,
data []byte,
) (results []detectors.Result, err error) {
dataStr := string(data)
uniqueTokens := make(map[string]struct{})
matches := pganalyzeTokenPat.FindAllStringSubmatch(dataStr, -1)
for _, match := range matches {
uniqueTokens[match[1]] = struct{}{}
}
for token := range uniqueTokens {
result := detectors.Result{
DetectorType: detector_typepb.DetectorType_PgAnalyzeReadKey,
Raw: []byte(token),
SecretParts: map[string]string{
"key": token,
"access_type": "read",
},
}
if verify {
verified, verificationErr := verifyPganalyzeToken(
ctx,
s.getClient(),
token,
)
result.SetVerificationError(verificationErr, token)
result.Verified = verified
}
results = append(results, result)
}
return
}
func verifyPganalyzeToken(
ctx context.Context,
client *http.Client,
token string,
) (bool, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"https://app.pganalyze.com/graphql",
http.NoBody,
)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Token "+token)
res, err := client.Do(req)
if err != nil {
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
switch res.StatusCode {
case http.StatusOK:
return true, nil
// Explicit invalid auth
case http.StatusUnauthorized:
return false, nil
default:
return false, fmt.Errorf(
"unexpected HTTP response status %d",
res.StatusCode,
)
}
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_PgAnalyzeReadKey
}
func (s Scanner) Description() string {
return "pganalyze is a PostgreSQL monitoring and performance analysis platform. pganalyze Read API keys can be used to access read-only monitoring and query performance data."
}
@@ -0,0 +1,215 @@
//go:build detectors
// +build detectors
package pganalyzereadkey
import (
"context"
"fmt"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
func TestPgAnalyzeReadKey_FromData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
testSecrets, err := common.GetSecret(
ctx,
"trufflehog-testing",
"detectors6",
)
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
activeToken := testSecrets.MustGetField("PGANALYZE_READ_KEY")
inactiveToken := "pgar_abcdefghijklmnopqrstuvwxyz1"
type args struct {
ctx context.Context
data []byte
verify bool
}
tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
wantVerificationErr bool
}{
{
name: "found, verified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: fmt.Appendf(
[]byte{},
"Using pganalyze read key %s for dashboard access",
activeToken,
),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_PgAnalyzeReadKey,
Verified: true,
Raw: []byte(activeToken),
},
},
},
{
name: "found, real token, verification error due to timeout",
s: Scanner{
client: common.SaneHttpClientTimeOut(1 * time.Microsecond),
},
args: args{
ctx: context.Background(),
data: fmt.Appendf(
[]byte{},
"Using pganalyze read key %s for dashboard access",
activeToken,
),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_PgAnalyzeReadKey,
Verified: false,
Raw: []byte(activeToken),
},
},
wantVerificationErr: true,
},
{
name: "found, real token, verification error due to unexpected api surface",
s: Scanner{
client: common.ConstantResponseHttpClient(500, "{}"),
},
args: args{
ctx: context.Background(),
data: fmt.Appendf(
[]byte{},
"Using pganalyze read key %s for dashboard access",
activeToken,
),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_PgAnalyzeReadKey,
Verified: false,
Raw: []byte(activeToken),
},
},
wantVerificationErr: true,
},
{
name: "found, unverified (inactive token)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: fmt.Appendf(
[]byte{},
"Using pganalyze read key %s for dashboard access",
inactiveToken,
),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_PgAnalyzeReadKey,
Verified: false,
Raw: []byte(inactiveToken),
},
},
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte("no secrets here"),
verify: true,
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.s.FromData(
tt.args.ctx,
tt.args.verify,
tt.args.data,
)
if (err != nil) != tt.wantErr {
t.Fatalf(
"PgAnalyzeReadKey.FromData() error = %v, wantErr %v",
err,
tt.wantErr,
)
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatal("no raw secret present")
}
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
t.Fatalf(
"wantVerificationError = %v, verification error = %v",
tt.wantVerificationErr,
got[i].VerificationError(),
)
}
}
ignoreOpts := cmpopts.IgnoreFields(
detectors.Result{},
"ExtraData",
"verificationError",
"primarySecret",
"SecretParts",
"chunkOffset",
"chunkOffsetSet",
)
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
t.Errorf(
"PgAnalyzeReadKey.FromData() %s diff: (-got +want)\n%s",
tt.name,
diff,
)
}
})
}
}
func BenchmarkPgAnalyzeReadKey_FromData(b *testing.B) {
ctx := context.Background()
s := Scanner{}
for name, data := range detectors.MustGetBenchmarkData() {
b.Run(name, func(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err := s.FromData(ctx, false, data)
if err != nil {
b.Fatal(err)
}
}
})
}
}
@@ -0,0 +1,138 @@
package pganalyzereadkey
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
)
func TestPgAnalyzeReadKey_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern - basic",
input: `
[INFO] pganalyze initialized
[DEBUG] token=pgar_abcdefghijklmnopqrstuvwxyz1
[INFO] done
`,
want: []string{
"pgar_abcdefghijklmnopqrstuvwxyz1",
},
},
{
name: "valid pattern - env variable",
input: `
PGANALYZE_READ_KEY=pgar_123456789012345678901234567
`,
want: []string{
"pgar_123456789012345678901234567",
},
},
{
name: "valid pattern - multiple tokens",
input: `
pgar_aaaaaaaaaaaaaaaaaaaaaaaaaaa
pgar_bbbbbbbbbbbbbbbbbbbbbbbbbbb
`,
want: []string{
"pgar_aaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pgar_bbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
},
{
name: "invalid pattern - uppercase prefix",
input: `
PGAR_abcdefghijklmnopqrstuvwxyz1
`,
want: nil,
},
{
name: "invalid pattern - too short",
input: `
pgar_12345
`,
want: nil,
},
{
name: "invalid pattern - too long",
input: `
pgar_abcdefghijklmnopqrstuvwxyz123456
`,
want: nil,
},
{
name: "invalid pattern - invalid characters",
input: `
pgar_abcde!ghijklmnopqrstuvwxyz12
`,
want: nil,
},
{
name: "invalid pattern - keyword only",
input: `
pgar_
`,
want: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 {
t.Errorf(
"test %q failed: expected keywords %v to be found in the input",
test.name,
d.Keywords(),
)
return
}
results, err := d.FromData(
context.Background(),
false,
[]byte(test.input),
)
require.NoError(t, err)
if len(results) != len(test.want) {
t.Errorf(
"mismatch in result count: expected %d, got %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)
}
})
}
}
+4
View File
@@ -563,6 +563,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/peopledatalabs"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/pepipost"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/percy"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/pganalyzereadkey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/photoroom"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/phraseaccesstoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/pinata"
@@ -1461,6 +1462,7 @@ func buildDetectorList() []detectors.Detector {
&peopledatalabs.Scanner{},
&pepipost.Scanner{},
&percy.Scanner{},
&pganalyzereadkey.Scanner{},
&photoroom.Scanner{},
&phraseaccesstoken.Scanner{},
&pinata.Scanner{},
@@ -1804,6 +1806,8 @@ func buildDetectorList() []detectors.Detector {
return !feature.UserDetectorEnabled.Load()
case *braintrust.Scanner:
return !feature.BraintrustDetectorEnabled.Load()
case *pganalyzereadkey.Scanner:
return !feature.PgAnalyzeReadKeyDetectorEnabled.Load()
default:
return false
}
+1
View File
@@ -135,6 +135,7 @@ var excludedFromDefaultList = map[detector_typepb.DetectorType]struct{}{
detector_typepb.DetectorType_Rev: {},
detector_typepb.DetectorType_User: {},
detector_typepb.DetectorType_BrainTrustApiKey: {},
detector_typepb.DetectorType_PgAnalyzeReadKey: {},
// Reserved / special types.
detector_typepb.DetectorType_CustomRegex: {}, // added dynamically via engine config, not via buildDetectorList()
+22 -21
View File
@@ -5,27 +5,28 @@ import (
)
var (
ForceSkipBinaries atomic.Bool
ForceSkipArchives atomic.Bool
GitCloneTimeoutDuration atomic.Int64
SkipAdditionalRefs atomic.Bool
EnableAPKHandler atomic.Bool
UserAgentSuffix AtomicString
UseSimplifiedGitlabEnumeration atomic.Bool
UseGitMirror atomic.Bool
GitlabProjectsPerPage atomic.Int64
UseGithubGraphQLAPI atomic.Bool // use github graphql api to fetch issues, pr's and comments
HTMLDecoderEnabled atomic.Bool
PineconeDetectorEnabled atomic.Bool
CloudinaryDetectorEnabled atomic.Bool
GitLabOAuthDetectorEnabled atomic.Bool
EnigmaDetectorEnabled atomic.Bool
DatadogApiKeyDetectorEnabled atomic.Bool
TlyDetectorEnabled atomic.Bool
WitDetectorEnabled atomic.Bool
RevDetectorEnabled atomic.Bool
UserDetectorEnabled atomic.Bool
BraintrustDetectorEnabled atomic.Bool
ForceSkipBinaries atomic.Bool
ForceSkipArchives atomic.Bool
GitCloneTimeoutDuration atomic.Int64
SkipAdditionalRefs atomic.Bool
EnableAPKHandler atomic.Bool
UserAgentSuffix AtomicString
UseSimplifiedGitlabEnumeration atomic.Bool
UseGitMirror atomic.Bool
GitlabProjectsPerPage atomic.Int64
UseGithubGraphQLAPI atomic.Bool // use github graphql api to fetch issues, pr's and comments
HTMLDecoderEnabled atomic.Bool
PineconeDetectorEnabled atomic.Bool
CloudinaryDetectorEnabled atomic.Bool
GitLabOAuthDetectorEnabled atomic.Bool
EnigmaDetectorEnabled atomic.Bool
DatadogApiKeyDetectorEnabled atomic.Bool
TlyDetectorEnabled atomic.Bool
WitDetectorEnabled atomic.Bool
RevDetectorEnabled atomic.Bool
UserDetectorEnabled atomic.Bool
BraintrustDetectorEnabled atomic.Bool
PgAnalyzeReadKeyDetectorEnabled atomic.Bool
)
type AtomicString struct {
+11 -6
View File
@@ -1107,6 +1107,7 @@ const (
DetectorType_SpectralOps DetectorType = 1051
DetectorType_AWSAppSync DetectorType = 1052
DetectorType_BrainTrustApiKey DetectorType = 1053
DetectorType_PgAnalyzeReadKey DetectorType = 1054
)
// Enum value maps for DetectorType.
@@ -2162,6 +2163,7 @@ var (
1051: "SpectralOps",
1052: "AWSAppSync",
1053: "BrainTrustApiKey",
1054: "PgAnalyzeReadKey",
}
DetectorType_value = map[string]int32{
"Alibaba": 0,
@@ -3214,6 +3216,7 @@ var (
"SpectralOps": 1051,
"AWSAppSync": 1052,
"BrainTrustApiKey": 1053,
"PgAnalyzeReadKey": 1054,
}
)
@@ -3249,7 +3252,7 @@ var File_detector_type_proto protoreflect.FileDescriptor
var file_detector_type_proto_rawDesc = []byte{
0x0a, 0x13, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x74, 0x79, 0x70, 0x65, 0x2a, 0xf9, 0x88, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x74, 0x79, 0x70, 0x65, 0x2a, 0x90, 0x89, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62,
0x61, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a,
0x03, 0x41, 0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10,
@@ -4345,11 +4348,13 @@ var file_detector_type_proto_rawDesc = []byte{
0x72, 0x61, 0x6c, 0x4f, 0x70, 0x73, 0x10, 0x9b, 0x08, 0x12, 0x0f, 0x0a, 0x0a, 0x41, 0x57, 0x53,
0x41, 0x70, 0x70, 0x53, 0x79, 0x6e, 0x63, 0x10, 0x9c, 0x08, 0x12, 0x15, 0x0a, 0x10, 0x42, 0x72,
0x61, 0x69, 0x6e, 0x54, 0x72, 0x75, 0x73, 0x74, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x10, 0x9d,
0x08, 0x42, 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f,
0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b,
0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79,
0x70, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x08, 0x12, 0x15, 0x0a, 0x10, 0x50, 0x67, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x52, 0x65,
0x61, 0x64, 0x4b, 0x65, 0x79, 0x10, 0x9e, 0x08, 0x42, 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68,
0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65,
0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f,
0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65,
0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
}
var (
+1
View File
@@ -1055,4 +1055,5 @@ enum DetectorType {
SpectralOps = 1051;
AWSAppSync = 1052;
BrainTrustApiKey = 1053;
PgAnalyzeReadKey = 1054;
}