Skip to content

Commit 1903b91

Browse files
Add private key id to GCP metadata (#4361)
* Add private key id to GCP metadata * update tests and key ID approach * fix glamour dep * update jwt * update circl
1 parent 3852af4 commit 1903b91

File tree

5 files changed

+203
-16
lines changed

5 files changed

+203
-16
lines changed

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ require (
170170
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
171171
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
172172
github.com/charmbracelet/x/term v0.2.1 // indirect
173-
github.com/cloudflare/circl v1.3.8 // indirect
173+
github.com/cloudflare/circl v1.6.1 // indirect
174174
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
175175
github.com/containerd/errdefs v1.0.0 // indirect
176176
github.com/containerd/errdefs/pkg v0.3.0 // indirect
@@ -206,7 +206,7 @@ require (
206206
github.com/go-ole/go-ole v1.2.6 // indirect
207207
github.com/gofrs/flock v0.12.1 // indirect
208208
github.com/gogo/protobuf v1.3.2 // indirect
209-
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
209+
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
210210
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
211211
github.com/golang-sql/sqlexp v0.1.0 // indirect
212212
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
214214
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
215215
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
216216
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
217+
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
218+
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
217219
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
218220
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
219221
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
@@ -360,6 +362,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
360362
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
361363
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
362364
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
365+
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
366+
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
363367
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
364368
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
365369
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=

pkg/detectors/gcp/gcp.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ package gcp
33
import (
44
"bytes"
55
"context"
6+
"crypto/rsa"
7+
"crypto/x509"
68
"encoding/json"
9+
"encoding/pem"
10+
"net/http"
11+
"net/url"
712
"strconv"
813
"strings"
914

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

126+
// Populate private_key_id by matching the private key to certificates from the x509 endpoint.
127+
// Only do this when verification is enabled to avoid network calls during fast scans/tests.
128+
// Falls back to the value present in the found data when fetching fails or is disabled.
129+
var privateKeyID string
130+
if verify && creds.PrivateKey != "" {
131+
certsURL := strings.TrimSpace(creds.ClientX509CertURL)
132+
if certsURL == "" && creds.ClientEmail != "" {
133+
certsURL = "https://www.googleapis.com/robot/v1/metadata/x509/" + url.PathEscape(creds.ClientEmail)
134+
}
135+
if certsURL != "" {
136+
if matchedKID, err := findMatchingCertificateKID(ctx, certsURL, creds.PrivateKey); err == nil && matchedKID != "" {
137+
privateKeyID = matchedKID
138+
}
139+
}
140+
}
141+
if privateKeyID == "" {
142+
privateKeyID = creds.PrivateKeyID
143+
}
144+
if result.ExtraData == nil {
145+
result.ExtraData = map[string]string{}
146+
}
147+
if privateKeyID != "" {
148+
result.ExtraData["private_key_id"] = privateKeyID
149+
}
150+
121151
if creds.Type != "" {
122152
result.AnalysisInfo["type"] = creds.Type
123153
}
@@ -149,6 +179,103 @@ func verifyMatch(ctx context.Context, credBytes []byte) (bool, error) {
149179
return true, nil
150180
}
151181

182+
// findMatchingCertificateKID fetches certificates from the x509 endpoint and finds the one
183+
// that matches the public key derived from the given private key.
184+
func findMatchingCertificateKID(ctx context.Context, certsURL, privateKeyPEM string) (string, error) {
185+
// Extract public key from private key
186+
privateKey, err := parsePrivateKey(privateKeyPEM)
187+
if err != nil {
188+
return "", err
189+
}
190+
191+
publicKey, ok := privateKey.(*rsa.PrivateKey)
192+
if !ok {
193+
return "", nil // Only RSA keys supported for now
194+
}
195+
196+
// Fetch certificates from endpoint
197+
kidToCert, err := fetchServiceAccountCerts(ctx, certsURL)
198+
if err != nil {
199+
return "", err
200+
}
201+
202+
// Compare public keys to find matching certificate
203+
for kid, certPEM := range kidToCert {
204+
cert, err := parseCertificate(certPEM)
205+
if err != nil {
206+
continue
207+
}
208+
209+
certPublicKey, ok := cert.PublicKey.(*rsa.PublicKey)
210+
if !ok {
211+
continue
212+
}
213+
214+
// Compare RSA public keys
215+
if publicKey.PublicKey.N.Cmp(certPublicKey.N) == 0 && publicKey.PublicKey.E == certPublicKey.E {
216+
return kid, nil
217+
}
218+
}
219+
220+
return "", nil // No matching certificate found
221+
}
222+
223+
// fetchServiceAccountCerts fetches the service account x509 certificates JSON.
224+
// Returns a map of kid -> PEM certificate string.
225+
func fetchServiceAccountCerts(ctx context.Context, certsURL string) (map[string]string, error) {
226+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, certsURL, nil)
227+
if err != nil {
228+
return nil, err
229+
}
230+
req.Header.Set("Accept", "application/json")
231+
232+
resp, err := http.DefaultClient.Do(req)
233+
if err != nil {
234+
return nil, err
235+
}
236+
defer resp.Body.Close()
237+
238+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
239+
return nil, nil
240+
}
241+
242+
var kidToCert map[string]string
243+
if err := json.NewDecoder(resp.Body).Decode(&kidToCert); err != nil {
244+
return nil, err
245+
}
246+
247+
return kidToCert, nil
248+
}
249+
250+
// parsePrivateKey parses a PEM-encoded private key
251+
func parsePrivateKey(privateKeyPEM string) (interface{}, error) {
252+
block, _ := pem.Decode([]byte(privateKeyPEM))
253+
if block == nil {
254+
return nil, nil
255+
}
256+
257+
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
258+
if err != nil {
259+
// Try PKCS1 if PKCS8 fails
260+
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
261+
if err != nil {
262+
return nil, err
263+
}
264+
}
265+
266+
return key, nil
267+
}
268+
269+
// parseCertificate parses a PEM-encoded certificate
270+
func parseCertificate(certPEM string) (*x509.Certificate, error) {
271+
block, _ := pem.Decode([]byte(certPEM))
272+
if block == nil {
273+
return nil, nil
274+
}
275+
276+
return x509.ParseCertificate(block.Bytes)
277+
}
278+
152279
func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) {
153280
return false, ""
154281
}

pkg/detectors/gcp/gcp_integration_test.go

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,13 @@ import (
2020
func TestGCP_FromChunk(t *testing.T) {
2121
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
2222
defer cancel()
23-
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors2")
23+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
2424
if err != nil {
2525
t.Fatalf("could not get test secrets from GCP: %s", err)
2626
}
2727
secret := testSecrets.MustGetField("GCP_SECRET")
2828
secretInactive := testSecrets.MustGetField("GCP_INACTIVE")
29-
30-
testSecrets2, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
31-
if err != nil {
32-
t.Fatalf("could not get test secrets from GCP: %s", err)
33-
}
34-
secretDisabled := testSecrets2.MustGetField("GCP_DISABLED")
29+
secretDisabled := testSecrets.MustGetField("GCP_DISABLED")
3530

3631
type args struct {
3732
ctx context.Context
@@ -57,7 +52,12 @@ func TestGCP_FromChunk(t *testing.T) {
5752
{
5853
DetectorType: detectorspb.DetectorType_GCP,
5954
Verified: true,
60-
Redacted: "[email protected]",
55+
Redacted: "[email protected]",
56+
ExtraData: map[string]string{
57+
"rotation_guide": "https://howtorotate.com/docs/tutorials/gcp/",
58+
"project": "thog-sandbox",
59+
"private_key_id": "a7c42dc3272c5462d1c1b5f7aadfe7ff1eecc87b",
60+
},
6161
},
6262
},
6363
wantErr: false,
@@ -74,7 +74,12 @@ func TestGCP_FromChunk(t *testing.T) {
7474
{
7575
DetectorType: detectorspb.DetectorType_GCP,
7676
Verified: false,
77-
Redacted: "secretcom",
77+
Redacted: "[email protected]",
78+
ExtraData: map[string]string{
79+
"rotation_guide": "https://howtorotate.com/docs/tutorials/gcp/",
80+
"project": "thog-sandbox",
81+
"private_key_id": "a7c42dc3272c5462d1c1b5f7aadfe7ff1eecc87b",
82+
},
7883
},
7984
},
8085
wantErr: false,
@@ -91,7 +96,12 @@ func TestGCP_FromChunk(t *testing.T) {
9196
{
9297
DetectorType: detectorspb.DetectorType_GCP,
9398
Verified: false,
94-
Redacted: "[email protected]",
99+
Redacted: "[email protected]",
100+
ExtraData: map[string]string{
101+
"rotation_guide": "https://howtorotate.com/docs/tutorials/gcp/",
102+
"project": "trufflehog-testing",
103+
"private_key_id": "95cf38cc5e63007aa066e8a710fc64c3554d77f4",
104+
},
95105
},
96106
},
97107
wantErr: false,
@@ -123,14 +133,59 @@ func TestGCP_FromChunk(t *testing.T) {
123133
}
124134
got[i].Raw = nil
125135
}
126-
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "ExtraData", "verificationError", "AnalysisInfo")
127-
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
136+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "AnalysisInfo")
137+
ignoreUnexported := cmpopts.IgnoreUnexported(detectors.Result{})
138+
if diff := cmp.Diff(got, tt.want, ignoreOpts, ignoreUnexported); diff != "" {
128139
t.Errorf("GCP.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
129140
}
130141
})
131142
}
132143
}
133144

145+
// TestGCP_KeyIDPopulation tests that the private_key_id is properly populated
146+
// in ExtraData, either from the x509 endpoint (when available) or falling back
147+
// to the embedded value in the JSON.
148+
func TestGCP_KeyIDPopulation(t *testing.T) {
149+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
150+
defer cancel()
151+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
152+
if err != nil {
153+
t.Fatalf("could not get test secrets from GCP: %s", err)
154+
}
155+
secret := testSecrets.MustGetField("GCP_SECRET")
156+
157+
s := Scanner{}
158+
results, err := s.FromData(ctx, true, []byte(secret))
159+
if err != nil {
160+
t.Fatalf("FromData() error = %v", err)
161+
}
162+
163+
if len(results) != 1 {
164+
t.Fatalf("Expected 1 result, got %d", len(results))
165+
}
166+
167+
result := results[0]
168+
169+
// Verify that private_key_id is populated in ExtraData
170+
privateKeyID, exists := result.ExtraData["private_key_id"]
171+
if !exists {
172+
t.Error("private_key_id not found in ExtraData")
173+
}
174+
175+
// Since the test service account is disabled (detector-test@trufflehog-testing),
176+
// the x509 endpoint returns 404, so we expect fallback to the embedded private_key_id from the JSON
177+
if privateKeyID == "" {
178+
t.Error("private_key_id should not be empty")
179+
}
180+
181+
// Verify it's a reasonable key ID format (hex string)
182+
if len(privateKeyID) < 20 { // typical GCP key IDs are 40 char hex
183+
t.Errorf("private_key_id '%s' seems too short for a typical GCP key ID", privateKeyID)
184+
}
185+
186+
t.Logf("private_key_id populated as: %s (fallback from embedded JSON due to disabled service account)", privateKeyID)
187+
}
188+
134189
func BenchmarkFromData(benchmark *testing.B) {
135190
ctx := context.Background()
136191
s := Scanner{}

pkg/tui/common/style.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package common
22

33
import (
4-
"github.com/charmbracelet/glamour"
54
gansi "github.com/charmbracelet/glamour/ansi"
5+
"github.com/charmbracelet/glamour/styles"
66
)
77

88
func strptr(s string) *string {
@@ -12,7 +12,8 @@ func strptr(s string) *string {
1212
// StyleConfig returns the default Glamour style configuration.
1313
func StyleConfig() gansi.StyleConfig {
1414
noColor := strptr("")
15-
s := glamour.DarkStyleConfig
15+
s := styles.DarkStyleConfig
16+
1617
s.H1.BackgroundColor = noColor
1718
s.H1.Prefix = "# "
1819
s.H1.Suffix = ""

0 commit comments

Comments
 (0)