Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.3.8 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
Expand Down Expand Up @@ -206,7 +206,7 @@ require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
Expand Down Expand Up @@ -360,6 +362,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
Expand Down
127 changes: 127 additions & 0 deletions pkg/detectors/gcp/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ package gcp
import (
"bytes"
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/url"
"strconv"
"strings"

Expand Down Expand Up @@ -118,6 +123,31 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
},
}

// Populate private_key_id by matching the private key to certificates from the x509 endpoint.
// Only do this when verification is enabled to avoid network calls during fast scans/tests.
// Falls back to the value present in the found data when fetching fails or is disabled.
var privateKeyID string
if verify && creds.PrivateKey != "" {
certsURL := strings.TrimSpace(creds.ClientX509CertURL)
if certsURL == "" && creds.ClientEmail != "" {
certsURL = "https://www.googleapis.com/robot/v1/metadata/x509/" + url.PathEscape(creds.ClientEmail)
}
if certsURL != "" {
if matchedKID, err := findMatchingCertificateKID(ctx, certsURL, creds.PrivateKey); err == nil && matchedKID != "" {
privateKeyID = matchedKID
}
}
}
if privateKeyID == "" {
privateKeyID = creds.PrivateKeyID
}
if result.ExtraData == nil {
result.ExtraData = map[string]string{}
}
if privateKeyID != "" {
result.ExtraData["private_key_id"] = privateKeyID
}

if creds.Type != "" {
result.AnalysisInfo["type"] = creds.Type
}
Expand Down Expand Up @@ -149,6 +179,103 @@ func verifyMatch(ctx context.Context, credBytes []byte) (bool, error) {
return true, nil
}

// findMatchingCertificateKID fetches certificates from the x509 endpoint and finds the one
// that matches the public key derived from the given private key.
func findMatchingCertificateKID(ctx context.Context, certsURL, privateKeyPEM string) (string, error) {
// Extract public key from private key
privateKey, err := parsePrivateKey(privateKeyPEM)
if err != nil {
return "", err
}

publicKey, ok := privateKey.(*rsa.PrivateKey)
if !ok {
return "", nil // Only RSA keys supported for now
}

// Fetch certificates from endpoint
kidToCert, err := fetchServiceAccountCerts(ctx, certsURL)
if err != nil {
return "", err
}

// Compare public keys to find matching certificate
for kid, certPEM := range kidToCert {
cert, err := parseCertificate(certPEM)
if err != nil {
continue
}

certPublicKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
continue
}

// Compare RSA public keys
if publicKey.PublicKey.N.Cmp(certPublicKey.N) == 0 && publicKey.PublicKey.E == certPublicKey.E {
return kid, nil
}
}

return "", nil // No matching certificate found
}

// fetchServiceAccountCerts fetches the service account x509 certificates JSON.
// Returns a map of kid -> PEM certificate string.
func fetchServiceAccountCerts(ctx context.Context, certsURL string) (map[string]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, certsURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, nil
}

var kidToCert map[string]string
if err := json.NewDecoder(resp.Body).Decode(&kidToCert); err != nil {
return nil, err
}

return kidToCert, nil
}

// parsePrivateKey parses a PEM-encoded private key
func parsePrivateKey(privateKeyPEM string) (interface{}, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, nil
}

key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
// Try PKCS1 if PKCS8 fails
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
}

return key, nil
}

// parseCertificate parses a PEM-encoded certificate
func parseCertificate(certPEM string) (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, nil
}

return x509.ParseCertificate(block.Bytes)
}

func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) {
return false, ""
}
Expand Down
79 changes: 67 additions & 12 deletions pkg/detectors/gcp/gcp_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,13 @@ import (
func TestGCP_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors2")
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
secret := testSecrets.MustGetField("GCP_SECRET")
secretInactive := testSecrets.MustGetField("GCP_INACTIVE")

testSecrets2, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
secretDisabled := testSecrets2.MustGetField("GCP_DISABLED")
secretDisabled := testSecrets.MustGetField("GCP_DISABLED")

type args struct {
ctx context.Context
Expand All @@ -57,7 +52,12 @@ func TestGCP_FromChunk(t *testing.T) {
{
DetectorType: detectorspb.DetectorType_GCP,
Verified: true,
Redacted: "[email protected]",
Redacted: "[email protected]",
ExtraData: map[string]string{
"rotation_guide": "https://howtorotate.com/docs/tutorials/gcp/",
"project": "thog-sandbox",
"private_key_id": "a7c42dc3272c5462d1c1b5f7aadfe7ff1eecc87b",
},
},
},
wantErr: false,
Expand All @@ -74,7 +74,12 @@ func TestGCP_FromChunk(t *testing.T) {
{
DetectorType: detectorspb.DetectorType_GCP,
Verified: false,
Redacted: "secretcom",
Redacted: "[email protected]",
ExtraData: map[string]string{
"rotation_guide": "https://howtorotate.com/docs/tutorials/gcp/",
"project": "thog-sandbox",
"private_key_id": "a7c42dc3272c5462d1c1b5f7aadfe7ff1eecc87b",
},
},
},
wantErr: false,
Expand All @@ -91,7 +96,12 @@ func TestGCP_FromChunk(t *testing.T) {
{
DetectorType: detectorspb.DetectorType_GCP,
Verified: false,
Redacted: "[email protected]",
Redacted: "[email protected]",
ExtraData: map[string]string{
"rotation_guide": "https://howtorotate.com/docs/tutorials/gcp/",
"project": "trufflehog-testing",
"private_key_id": "95cf38cc5e63007aa066e8a710fc64c3554d77f4",
},
},
},
wantErr: false,
Expand Down Expand Up @@ -123,14 +133,59 @@ func TestGCP_FromChunk(t *testing.T) {
}
got[i].Raw = nil
}
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "ExtraData", "verificationError", "AnalysisInfo")
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "AnalysisInfo")
ignoreUnexported := cmpopts.IgnoreUnexported(detectors.Result{})
if diff := cmp.Diff(got, tt.want, ignoreOpts, ignoreUnexported); diff != "" {
t.Errorf("GCP.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}

// TestGCP_KeyIDPopulation tests that the private_key_id is properly populated
// in ExtraData, either from the x509 endpoint (when available) or falling back
// to the embedded value in the JSON.
func TestGCP_KeyIDPopulation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
secret := testSecrets.MustGetField("GCP_SECRET")

s := Scanner{}
results, err := s.FromData(ctx, true, []byte(secret))
if err != nil {
t.Fatalf("FromData() error = %v", err)
}

if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}

result := results[0]

// Verify that private_key_id is populated in ExtraData
privateKeyID, exists := result.ExtraData["private_key_id"]
if !exists {
t.Error("private_key_id not found in ExtraData")
}

// Since the test service account is disabled (detector-test@trufflehog-testing),
// the x509 endpoint returns 404, so we expect fallback to the embedded private_key_id from the JSON
if privateKeyID == "" {
t.Error("private_key_id should not be empty")
}

// Verify it's a reasonable key ID format (hex string)
if len(privateKeyID) < 20 { // typical GCP key IDs are 40 char hex
t.Errorf("private_key_id '%s' seems too short for a typical GCP key ID", privateKeyID)
}

t.Logf("private_key_id populated as: %s (fallback from embedded JSON due to disabled service account)", privateKeyID)
}

func BenchmarkFromData(benchmark *testing.B) {
ctx := context.Background()
s := Scanner{}
Expand Down
5 changes: 3 additions & 2 deletions pkg/tui/common/style.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package common

import (
"github.com/charmbracelet/glamour"
gansi "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
)

func strptr(s string) *string {
Expand All @@ -12,7 +12,8 @@ func strptr(s string) *string {
// StyleConfig returns the default Glamour style configuration.
func StyleConfig() gansi.StyleConfig {
noColor := strptr("")
s := glamour.DarkStyleConfig
s := styles.DarkStyleConfig

s.H1.BackgroundColor = noColor
s.H1.Prefix = "# "
s.H1.Suffix = ""
Expand Down
Loading