Skip to content

Deduplicate concurrent credential verification requests via singleflight#4314

Merged
kashifkhan0771 merged 41 commits into
trufflesecurity:mainfrom
kashifkhan0771:update/oss-257
Apr 28, 2026
Merged

Deduplicate concurrent credential verification requests via singleflight#4314
kashifkhan0771 merged 41 commits into
trufflesecurity:mainfrom
kashifkhan0771:update/oss-257

Conversation

@kashifkhan0771

@kashifkhan0771 kashifkhan0771 commented Jul 14, 2025

Copy link
Copy Markdown
Contributor

Description:

Details: #2262

When TruffleHog scans at high concurrency, the same credential can appear in multiple chunks being processed simultaneously. Before the engine-level VerificationCache has stored a result, several detector workers can race to verify the same credential, each making an identical outbound HTTP request. This PR eliminates those redundant calls by introducing a singleflightTransport - an http.RoundTripper wrapper that coalesces concurrent requests sharing the same detector-type-namespaced dedup key into a single network call, buffering and replaying the response to all waiting goroutines. Detectors opt in with two lines: wrapping their client with detectors.NewClientWithDedup and stamping the request context with detectors.WithDedupKey(ctx, s.Type(), credential) before calling client.Do. This PR adopts the pattern across 16 detectors as an initial rollout.

Note: This is the first example implementation, if approved we will implement this in the most used detectors.

Checklist:

  • Tests passing (make test-community)?
  • Lint passing (make lint this requires golangci-lint)?

Note

Medium Risk
Adds a new http.RoundTripper that buffers and replays responses while coalescing concurrent verification requests; mistakes could change verification behavior, timeouts, or cancellation semantics across multiple detectors.

Overview
Introduces opt-in HTTP request deduplication for secret verification using a singleflight-backed transport (NewClientWithDedup + DoWithDedup), so concurrent verifications of the same detector type + credential share one outbound request and replay a buffered response.

Rolls this pattern out across several detectors by wrapping their clients and routing verification requests through DoWithDedup, and adds focused tests for coalescing, cancellation, and deadline behavior. Also tweaks plain output messaging to explicitly indicate when cached verification is used.

Reviewed by Cursor Bugbot for commit f6edad9. Bugbot is set up for automated code reviews on this repo. Configure here.

@kashifkhan0771 kashifkhan0771 self-assigned this Jul 14, 2025
@kashifkhan0771 kashifkhan0771 requested review from a team July 14, 2025 10:54
@kashifkhan0771 kashifkhan0771 requested a review from a team as a code owner July 14, 2025 10:54
@bugbaba

bugbaba commented Jul 14, 2025

Copy link
Copy Markdown

Hi @kashifkhan0771,

Just gave it a quick test, its calling verify function twice if same key is found in two separate files.

image

@kashifkhan0771

Copy link
Copy Markdown
Contributor Author

Just gave it a quick test, its calling verify function twice if same key is found in two separate files.

I believe this is related to the issue you mentioned here

@trufflesteeeve

Copy link
Copy Markdown
Contributor

This is a cool caching implementation. I did a quick read of the related discussions (thank you very much for the links to additional context) and think that prior to us trying this idea, we should probably investigate improvements to the existing verification cache (e.g. making it worker-aware/thread-safe) before adding a second cache implementation.

Something like this very well might be where we end up, but first trying to improve what we already have could help reduce potential future complexity.

@kashifkhan0771

Copy link
Copy Markdown
Contributor Author

Just gave it a quick test, its calling verify function twice if same key is found in two separate files.

@bugbaba For me it was using cached result for duplicate findings from different files with previous changes as well. However, as you mentioned, I implemented per-secret locking. This ensures that each secret is locked during verification until it's either verified or cached.

Please give it a try and let me know how it goes.

Also, I'm converting this PR back to a draft based on @trufflesteeeve comment. I agree that we should first focus on fixing the existing caching implementation. This PR will remain here for reference or in case we need it in the future.

@kashifkhan0771 kashifkhan0771 marked this pull request as draft July 15, 2025 06:44
@ahrav

ahrav commented Jul 15, 2025

Copy link
Copy Markdown
Contributor

@kashifkhan0771 I’m not sure if you’ve considered using Go’s singleflight package to consolidate concurrent requests to the same endpoint into one. We could build a shared abstraction that all detectors use: whenever multiple goroutines ask for the same data at the same time, singleflight coalesces them into a single API call and fans out the response.

This won’t eliminate every duplicate request, requests separated by cache-miss delays can still slip through, but it’s essentially “free” to layer on and can cut down a lot of in-flight calls. For more background, see VictoriaMetrics’ blog on singleflight.

@kashifkhan0771

Copy link
Copy Markdown
Contributor Author

@kashifkhan0771 I’m not sure if you’ve considered using Go’s singleflight package to consolidate concurrent requests to the same endpoint into one. We could build a shared abstraction that all detectors use: whenever multiple goroutines ask for the same data at the same time, singleflight coalesces them into a single API call and fans out the response.

This won’t eliminate every duplicate request, requests separated by cache-miss delays can still slip through, but it’s essentially “free” to layer on and can cut down a lot of in-flight calls. For more background, see VictoriaMetrics’ blog on singleflight.

Hands down, you always bring great insights 🙌🏻 I really appreciate it. I hadn’t come across singleflight before, but I did have a similar idea in mind about collapsing multiple requests into one. I didn’t realize Go had a package for this, so thanks a lot for pointing it out.

I’ll read through the documentation and blog post to get a better understanding of how to use it effectively. For now, I’ll keep this PR in draft and start incorporating some changes. Let’s see how things go.

@kashifkhan0771

Copy link
Copy Markdown
Contributor Author

I have implemented singleflight for the GitHub detector for now. Please review and give it a try when you get a chance. I'm also thinking through how we can generalize this for all detectors, as @ahrav suggested.

@kashifkhan0771 kashifkhan0771 changed the title Added Detector-level cache to store verification results [Testing] Added Detector-level cache to store verification results Jul 15, 2025
@amanfcp

amanfcp commented Jul 17, 2025

Copy link
Copy Markdown
Contributor

@kashifkhan0771 I’m not sure if you’ve considered using Go’s singleflight package to consolidate concurrent requests to the same endpoint into one. We could build a shared abstraction that all detectors use: whenever multiple goroutines ask for the same data at the same time, singleflight coalesces them into a single API call and fans out the response.

This won’t eliminate every duplicate request, requests separated by cache-miss delays can still slip through, but it’s essentially “free” to layer on and can cut down a lot of in-flight calls. For more background, see VictoriaMetrics’ blog on singleflight.

Great read @ahrav. Thanks for sharing

@kashifkhan0771

Copy link
Copy Markdown
Contributor Author

@ahrav I switched to using only singleflight for the verification logic. I chose a much simpler detector which includes response body handling. I believe this approach should effectively filter out duplicate verification requests happening concurrently. What do you think?

@kashifkhan0771 kashifkhan0771 changed the title [Testing] Added Detector-level cache to store verification results [Testing] Avoid duplicate verification requests which happen in concurrently Aug 15, 2025
@kashifkhan0771 kashifkhan0771 changed the title [Testing] Avoid duplicate verification requests which happen in concurrently [Testing] Avoid duplicate verification requests which happen concurrently Aug 15, 2025
Comment thread pkg/detectors/meraki/meraki.go
Comment thread pkg/detectors/detectors.go Outdated
@kashifkhan0771 kashifkhan0771 marked this pull request as draft April 21, 2026 16:19
@kashifkhan0771 kashifkhan0771 marked this pull request as ready for review April 22, 2026 06:57
@kashifkhan0771 kashifkhan0771 changed the title [Testing] Avoid duplicate verification requests which happen concurrently Deduplicate concurrent credential verification requests via singleflight Apr 22, 2026

@rosecodym rosecodym left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty cool! My main comment has to do with composability of this new HTTP request logic with some existing custom HTTP request logic - I flagged it inline.

Comment thread pkg/detectors/detectors.go Outdated
// verification requests sharing the same key. Detectors opt in per credential by
// calling WithDedupKey on the request context before client.Do — no other changes
// to request building or response reading are needed.
func NewClientWithDedup(base *http.Client) *http.Client {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to compose this new deduplication logic with the loopback prevention enabled by some of the code in detectors/http.go? Even if it is possible, I find it confusing that they're located in different files. How much work would unifying them be? I think that would aid maintainability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Call - unifying them should only take a small amount of effort.

Comment thread pkg/detectors/detectors.go Outdated
Comment on lines +377 to +379
if !ok || key == "" {
return t.base.RoundTrip(req)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that if a detector uses the singleflight transport but doesn't attach a dedup key, they won't be deduped, and this behavior will be silent? That's better than an error, but I still worry that people will do one but not the other. Unfortunately, I can't think of an obvious solution - static analysis would be great, but I don't know if it's feasible here, and it doesn't look like you have a logger to use to notify users at runtime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WithDedupKey is now unexported withDedupKey.
The only public interface is DoWithDedup(client,detType, credential, req) - a single call that atomically stamps the context key and executes the request. There is no longer a two-step pattern to get wrong.
Calling client.Do(req) instead of DoWithDedup still exists, but it's now a much more visible mistake: it's a different function name right at the call site, easy to catch in review.

Comment thread pkg/detectors/http.go
Comment thread pkg/detectors/http.go
Comment thread pkg/detectors/http.go
Comment thread pkg/detectors/http.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 0df99b0. Configure here.

Comment thread pkg/detectors/http.go
Status: fmt.Sprintf("%d %s", br.statusCode, http.StatusText(br.statusCode)),
Header: br.header.Clone(),
Body: io.NopCloser(bytes.NewReader(br.body)),
}, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconstructed HTTP response missing key fields

Low Severity

The http.Response constructed for coalesced callers omits ContentLength (defaults to 0 despite body having data), Proto, ProtoMajor, and ProtoMinor. While current detectors only inspect StatusCode and Body, this creates an incomplete response object. Since the PR plans to roll this out across many more detectors, any future detector or middleware that reads resp.ContentLength or protocol version fields will silently get incorrect zero values.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0df99b0. Configure here.

Comment thread pkg/detectors/http.go
Comment on lines +209 to +217
// Detach the in-flight request from the first caller's cancellation so
// that one goroutine timing out doesn't abort the shared network call
// and propagate an error to all coalesced waiters.
//
// context.WithoutCancel also strips any deadline (e.g. from
// http.Client.Timeout), so we re-attach the original deadline if
// present. Without this the shared request has no timeout and a
// hanging server would leak the goroutine and pin the singleflight
// key indefinitely.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic looks both pretty gnarly and pretty important. Is it covered by the tests you added?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, I'll add them.

Comment thread pkg/detectors/detectors_test.go Outdated
// same detector type and credential are coalesced into one network call. Each request
// the server receives returns a distinct body, so all goroutines should observe the
// body from exactly one actual server-side request.
func TestDoWithDedup_Singleflight(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expected to see this test alongside http.go, not here. Should it have been moved when you moved the new transport code?

@rosecodym rosecodym left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this!

@MuneebUllahKhan222 MuneebUllahKhan222 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is really nice especially the place where you are handling and respecting the context of each caller is top tier. Also I got to learn something new i.e singleFlight so yay for that.

Just left a non-blocking comment you should or shouldn't handle depending on how you feel about it.

Comment thread pkg/detectors/http.go

// bufferedResponse holds a fully-read HTTP response so it can be replayed to
// every goroutine that was coalesced by singleflight.
type bufferedResponse struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we are at it I would suggest that we handle the issue bugbot is pointing at by updating this struct to

type bufferedResponse struct {
	statusCode   int
	header       http.Header
	body         []byte
	proto        string
	protoMajor   int
	protoMinor   int
	contentLength int64
}

and then populating it like this

return &bufferedResponse{
	statusCode:   resp.StatusCode,
	header:       resp.Header.Clone(),
	body:         body,
	proto:        resp.Proto,
	protoMajor:   resp.ProtoMajor,
	protoMinor:   resp.ProtoMinor,
	contentLength: int64(len(body)),
}, nil

and finally we can fully construct the http.Response

return &http.Response{
	StatusCode:    br.statusCode,
	Status:        fmt.Sprintf("%d %s", br.statusCode, http.StatusText(br.statusCode)),
	Header:        br.header.Clone(),
	Body:          io.NopCloser(bytes.NewReader(br.body)),
	Proto:         br.proto,
	ProtoMajor:    br.protoMajor,
	ProtoMinor:    br.protoMinor,
	ContentLength: br.contentLength,
}, nil

@kashifkhan0771 kashifkhan0771 merged commit 99dc7bd into trufflesecurity:main Apr 28, 2026
16 checks passed
MuneebUllahKhan222 pushed a commit that referenced this pull request May 29, 2026
…ght (#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants