Skip to content

Commit 99dc7bd

Browse files
Deduplicate concurrent credential verification requests via singleflight (#4314)
* Cache verification info for reuse * updated hashing * tried fixing concurrent verification issue * Added singleflight to avoid concurrent verification * resolved linter * Ok I tried something new for a much simpler detector * some enhancements * Added test case * re-added tags * Deduplicate concurrent credential verification requests via singleflight * Enforce dedup key via DoWithDedup, remove public WithDedupKey * remove unused func * Remove dead io.Copy after io.ReadAll error in singleflight transport * Preserve deadline on shared singleflight request after WithoutCancel * Added test cases and moved existing ones to http_test.go * fixed linter
1 parent 3fc0c2a commit 99dc7bd

20 files changed

Lines changed: 364 additions & 40 deletions

File tree

pkg/detectors/cliengo/cliengo.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type Scanner struct{}
1818
var _ detectors.Detector = (*Scanner)(nil)
1919

2020
var (
21-
client = common.SaneHttpClient()
21+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2222

2323
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
2424
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"cliengo"}) + `\b([0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12})\b`)
@@ -50,7 +50,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
5050
if err != nil {
5151
continue
5252
}
53-
res, err := client.Do(req)
53+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_Cliengo, resMatch, req)
5454
if err == nil {
5555
defer res.Body.Close()
5656
if res.StatusCode >= 200 && res.StatusCode < 300 {

pkg/detectors/clockify/clockify.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type Scanner struct{}
1818
var _ detectors.Detector = (*Scanner)(nil)
1919

2020
var (
21-
client = common.SaneHttpClient()
21+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2222

2323
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
2424
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"clockify"}) + `\b([a-zA-Z0-9]{48})\b`)
@@ -52,7 +52,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
5252
}
5353
req.Header.Add("content-type", "application/json")
5454
req.Header.Add("X-Api-Key", resMatch)
55-
res, err := client.Do(req)
55+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_Clockify, resMatch, req)
5656
if err == nil {
5757
defer res.Body.Close()
5858
if res.StatusCode >= 200 && res.StatusCode < 300 {

pkg/detectors/databox/databox.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Scanner struct{}
2020
var _ detectors.Detector = (*Scanner)(nil)
2121

2222
var (
23-
client = common.SaneHttpClient()
23+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2424

2525
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives
2626
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"databox"}) + common.BuildRegex(common.RegexPattern, "", 21))
@@ -65,7 +65,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
6565
req.Header.Add("Content-Type", "application/json")
6666
req.Header.Add("Accept", "application/vnd.databox.v2+json")
6767
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", sEnc))
68-
res, err := client.Do(req)
68+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_Databox, resMatch, req)
6969
if err == nil {
7070
defer res.Body.Close()
7171
if res.StatusCode >= 200 && res.StatusCode < 300 {

pkg/detectors/detectors.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
"crypto/rand"
66
"errors"
7+
"fmt"
78
"math/big"
9+
"net/http"
810
"net/url"
911
"strings"
1012
"unicode"
@@ -335,3 +337,20 @@ func ParseURLAndStripPathAndParams(u string) (*url.URL, error) {
335337
parsedURL.RawQuery = ""
336338
return parsedURL, nil
337339
}
340+
341+
type dedupKeyContextKey struct{}
342+
343+
func withDedupKey(ctx context.Context, detType detector_typepb.DetectorType, credential string) context.Context {
344+
key := fmt.Sprintf("%d:%s", int32(detType), credential)
345+
return context.WithValue(ctx, dedupKeyContextKey{}, key)
346+
}
347+
348+
// DoWithDedup executes req through client, coalescing concurrent requests that share
349+
// the same detector type and credential into a single network call via singleflight.
350+
// The response body is fully buffered and replayed to every waiting caller.
351+
//
352+
// Use this instead of client.Do for all verification requests on a client created
353+
// with NewClientWithDedup or WithDedup — it is the only way to activate deduplication.
354+
func DoWithDedup(client *http.Client, detType detector_typepb.DetectorType, credential string, req *http.Request) (*http.Response, error) {
355+
return client.Do(req.WithContext(withDedupKey(req.Context(), detType, credential)))
356+
}

pkg/detectors/finage/finage.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type Scanner struct{}
1919
var _ detectors.Detector = (*Scanner)(nil)
2020

2121
var (
22-
client = common.SaneHttpClient()
22+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2323

2424
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
2525
keyPat = regexp.MustCompile(`\b(API_KEY[0-9A-Z]{32})\b`)
@@ -52,7 +52,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
5252
continue
5353
}
5454
req.Header.Add("Content-Type", "application/json")
55-
res, err := client.Do(req)
55+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_Finage, resMatch, req)
5656
if err == nil {
5757
defer res.Body.Close()
5858
if res.StatusCode >= 200 && res.StatusCode < 300 {

pkg/detectors/geocode/geocode.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Scanner struct{}
2020
var _ detectors.Detector = (*Scanner)(nil)
2121

2222
var (
23-
client = common.SaneHttpClient()
23+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2424

2525
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
2626
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"geocode"}) + `\b([a-z0-9]{28})\b`)
@@ -52,7 +52,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
5252
if err != nil {
5353
continue
5454
}
55-
res, err := client.Do(req)
55+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_Geocode, resMatch, req)
5656
if err == nil {
5757
bodyBytes, err := io.ReadAll(res.Body)
5858
if err == nil {

pkg/detectors/gitter/gitter.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type Scanner struct{}
1919
var _ detectors.Detector = (*Scanner)(nil)
2020

2121
var (
22-
client = common.SaneHttpClient()
22+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2323

2424
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
2525
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"gitter"}) + `\b([a-z0-9-]{40})\b`)
@@ -52,7 +52,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
5252
continue
5353
}
5454
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", resMatch))
55-
res, err := client.Do(req)
55+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_Gitter, resMatch, req)
5656
if err == nil {
5757
defer res.Body.Close()
5858
if res.StatusCode >= 200 && res.StatusCode < 300 {

pkg/detectors/holidayapi/holidayapi.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type Scanner struct{}
1919
var _ detectors.Detector = (*Scanner)(nil)
2020

2121
var (
22-
client = common.SaneHttpClient()
22+
client = detectors.NewClientWithDedup(common.SaneHttpClient())
2323

2424
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
2525
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"holidayapi"}) + `\b([a-z0-9-]{36})\b`)
@@ -51,7 +51,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
5151
if err != nil {
5252
continue
5353
}
54-
res, err := client.Do(req)
54+
res, err := detectors.DoWithDedup(client, detector_typepb.DetectorType_HolidayAPI, resMatch, req)
5555
if err == nil {
5656
defer res.Body.Close()
5757
if res.StatusCode >= 200 && res.StatusCode < 300 {

pkg/detectors/http.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package detectors
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
7+
"fmt"
8+
"io"
69
"net"
710
"net/http"
811
"slices"
912
"sync"
1013
"time"
1114

15+
"golang.org/x/sync/singleflight"
16+
1217
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
1318
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
1419
)
@@ -175,3 +180,93 @@ func NewDetectorHttpClient(opts ...ClientOption) *http.Client {
175180
client.Transport = common.NewInstrumentedTransport(client.Transport)
176181
return client
177182
}
183+
184+
// bufferedResponse holds a fully-read HTTP response so it can be replayed to
185+
// every goroutine that was coalesced by singleflight.
186+
type bufferedResponse struct {
187+
statusCode int
188+
header http.Header
189+
body []byte
190+
}
191+
192+
// singleflightTransport is an http.RoundTripper that coalesces concurrent requests
193+
// sharing the same deduplication key into a single network call. It is a no-op for
194+
// requests whose context does not carry a dedup key.
195+
type singleflightTransport struct {
196+
base http.RoundTripper
197+
group singleflight.Group
198+
}
199+
200+
func (t *singleflightTransport) RoundTrip(req *http.Request) (*http.Response, error) {
201+
key, ok := req.Context().Value(dedupKeyContextKey{}).(string)
202+
if !ok || key == "" {
203+
return t.base.RoundTrip(req)
204+
}
205+
206+
// DoChan is used instead of Do so each caller can independently respect its
207+
// own context cancellation without blocking on the shared in-flight call.
208+
ch := t.group.DoChan(key, func() (any, error) {
209+
// Detach the in-flight request from the first caller's cancellation so
210+
// that one goroutine timing out doesn't abort the shared network call
211+
// and propagate an error to all coalesced waiters.
212+
//
213+
// context.WithoutCancel also strips any deadline (e.g. from
214+
// http.Client.Timeout), so we re-attach the original deadline if
215+
// present. Without this the shared request has no timeout and a
216+
// hanging server would leak the goroutine and pin the singleflight
217+
// key indefinitely.
218+
sharedCtx := context.WithoutCancel(req.Context())
219+
if deadline, ok := req.Context().Deadline(); ok {
220+
var cancel context.CancelFunc
221+
sharedCtx, cancel = context.WithDeadline(sharedCtx, deadline)
222+
defer cancel()
223+
}
224+
sharedReq := req.WithContext(sharedCtx)
225+
resp, err := t.base.RoundTrip(sharedReq)
226+
if err != nil {
227+
return nil, err
228+
}
229+
defer resp.Body.Close()
230+
231+
body, err := io.ReadAll(resp.Body)
232+
if err != nil {
233+
return nil, err
234+
}
235+
236+
return &bufferedResponse{
237+
statusCode: resp.StatusCode,
238+
header: resp.Header.Clone(),
239+
body: body,
240+
}, nil
241+
})
242+
243+
select {
244+
case result := <-ch:
245+
if result.Err != nil {
246+
return nil, result.Err
247+
}
248+
br := result.Val.(*bufferedResponse)
249+
return &http.Response{
250+
StatusCode: br.statusCode,
251+
Status: fmt.Sprintf("%d %s", br.statusCode, http.StatusText(br.statusCode)),
252+
Header: br.header.Clone(),
253+
Body: io.NopCloser(bytes.NewReader(br.body)),
254+
}, nil
255+
case <-req.Context().Done():
256+
return nil, req.Context().Err()
257+
}
258+
}
259+
260+
// NewClientWithDedup wraps base with a transport that deduplicates concurrent
261+
// verification requests sharing the same key. Detectors opt in per credential by
262+
// calling WithDedupKey on the request context before client.Do — no other changes
263+
// to request building or response reading are needed.
264+
func NewClientWithDedup(base *http.Client) *http.Client {
265+
clone := *base
266+
transport := base.Transport
267+
if transport == nil {
268+
transport = http.DefaultTransport
269+
}
270+
clone.Transport = &singleflightTransport{base: transport}
271+
return &clone
272+
}

0 commit comments

Comments
 (0)