Skip to content

Commit 37b7700

Browse files
adding customizable successRanges and rotatedRanges to customDetector (#4892)
* adding customizable successRanges and rotatedRanges to customDetector *setting definitive = true in the legacy 200 path so that an earlier ranged verifier's rangesInEffect = true can't trigger a spurious SetVerificationError after the legacy verifier already confirmed the secret as live. * adressed review comments
1 parent ba0a524 commit 37b7700

7 files changed

Lines changed: 445 additions & 37 deletions

File tree

pkg/custom_detectors/CUSTOM_DETECTORS.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,34 @@ This guide will walk you through setting up a custom detector in TruffleHog to i
3636
- **`regex`**: Defines the patterns to identify potential secrets. You can specify one or more named regular expressions. For a detection to be successful, each named regex must find a match. Capture groups `()` within these regular expressions are used to extract specific portions of the matched text, enabling the detector to process and report on particular segments of the identified patterns.
3737

3838
- **`verify`**: An optional section to validate detected secrets. If you want to verify or unverify detected secrets, this section needs to be configured. If not configured, all detected secrets will be marked as unverified. Read [verification server examples](#verification-server-examples)
39+
- **`successRanges`**: A list of HTTP status codes (or ranges) that indicate the secret is **live** (active). When the verification server responds with a matching status code, the secret is marked as verified. Each entry can be a single code (`"200"`) or an inclusive range (`"200-202"`). If omitted (along with `rotatedRanges`), only `200` is treated as verified (backward compatible).
40+
- **`rotatedRanges`**: A list of HTTP status codes (or ranges) that indicate the secret has been **rotated** (no longer active). When the verification server responds with a matching status code, the secret is definitively marked as unverified.
41+
42+
When only one of the two fields is configured, non-matching responses are treated as the opposite state (e.g., if only `successRanges` is set, any response that doesn't match is treated as rotated; if only `rotatedRanges` is set, any non-matching response is treated as live). When both fields are configured and the response matches neither, the result is treated as unknown/inconclusive.
43+
44+
Here's an example with configurable verification ranges:
45+
46+
```yaml
47+
# config.yaml
48+
detectors:
49+
- name: HogTokenDetector
50+
keywords:
51+
- hog
52+
regex:
53+
token: '[^A-Za-z0-9+\/]{0,1}([A-Za-z0-9+\/]{40})[^A-Za-z0-9+\/]{0,1}'
54+
verify:
55+
- endpoint: http://localhost:8000/
56+
unsafe: true
57+
headers:
58+
- "Authorization: super secret authorization header"
59+
successRanges:
60+
- "200"
61+
rotatedRanges:
62+
- "401"
63+
- "403"
64+
```
65+
66+
In this example, a `200` response from the verification server means the secret is live and needs rotation. A `401` or `403` means the secret has been rotated and is no longer active. Any other response is treated as inconclusive.
3967

4068
**Other allowed parameters:**
4169
- **`primary_regex_name`**: This parameter allows you designate the primary regex pattern when multiple regex patterns are defined in the regex section. If a match is found, the match for the designated primary regex will be used to determine the line number. The value must be one of the names specified in the regex section. If not provided, the first regex name in sorted order will be used as the primary regex by default.

pkg/custom_detectors/custom_detectors.go

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"io"
89
"maps"
910
"net/http"
@@ -64,6 +65,12 @@ func NewWebhookCustomRegex(pb *custom_detectorspb.CustomRegex) (*CustomRegexWebh
6465
if err := ValidateVerifyHeaders(verify.Headers); err != nil {
6566
return nil, err
6667
}
68+
if err := ValidateVerifyRanges(verify.SuccessRanges); err != nil {
69+
return nil, err
70+
}
71+
if err := ValidateVerifyRanges(verify.RotatedRanges); err != nil {
72+
return nil, err
73+
}
6774
}
6875

6976
// Ensure primary regex name is set.
@@ -275,10 +282,15 @@ func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string
275282
// disrupt other verification.
276283
return nil
277284
}
278-
// Try each config until we successfully verify.
285+
286+
var (
287+
definitive bool
288+
rangesInEffect bool
289+
)
290+
291+
// Try each config until we get a definitive answer.
279292
for _, verifyConfig := range c.GetVerify() {
280293
if common.IsDone(ctx) {
281-
// TODO: Log we're possibly leaving out results.
282294
return ctx.Err()
283295
}
284296
req, err := http.NewRequestWithContext(ctx, "POST", verifyConfig.GetEndpoint(), bytes.NewReader(jsonBody))
@@ -288,7 +300,6 @@ func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string
288300
for _, header := range verifyConfig.GetHeaders() {
289301
key, value, found := strings.Cut(header, ":")
290302
if !found {
291-
// Should be unreachable due to validation.
292303
continue
293304
}
294305
req.Header.Add(key, strings.TrimLeft(value, "\t\n\v\f\r "))
@@ -305,27 +316,58 @@ func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string
305316
_ = resp.Body.Close()
306317
}()
307318

308-
if resp.StatusCode == http.StatusOK {
309-
// mark the result as verified
310-
result.Verified = true
319+
successRanges := verifyConfig.GetSuccessRanges()
320+
rotatedRanges := verifyConfig.GetRotatedRanges()
311321

312-
body, err := io.ReadAll(resp.Body)
313-
if err != nil {
314-
continue
322+
if len(successRanges) == 0 && len(rotatedRanges) == 0 {
323+
// Backward compat: no ranges configured, use legacy behavior.
324+
if resp.StatusCode == http.StatusOK {
325+
result.Verified = true
326+
definitive = true
327+
storeResponseBody(resp, result.ExtraData)
328+
break
315329
}
330+
// Legacy non-200 is a meaningful response (verifier said "no");
331+
// mark definitive so a prior ranged verifier with rangesInEffect
332+
// does not cause a spurious verification error.
333+
definitive = true
334+
continue
335+
}
316336

317-
// TODO: handle different content-type responses seperatly when implement custom detector configurations
318-
responseStr := string(body)
319-
// truncate to 200 characters if response length exceeds 200
320-
if len(responseStr) > 200 {
321-
responseStr = responseStr[:200]
322-
}
337+
rangesInEffect = true
338+
bothConfigured := len(successRanges) > 0 && len(rotatedRanges) > 0
323339

324-
// store the processed response in ExtraData
325-
result.ExtraData["response"] = responseStr
340+
if StatusCodeMatchesRanges(resp.StatusCode, successRanges) {
341+
result.Verified = true
342+
definitive = true
343+
storeResponseBody(resp, result.ExtraData)
344+
break
345+
}
326346

347+
if StatusCodeMatchesRanges(resp.StatusCode, rotatedRanges) {
348+
definitive = true
327349
break
328350
}
351+
352+
// Status matched neither configured range.
353+
if !bothConfigured {
354+
// Only one side was configured: the non-matching response is
355+
// treated as the opposite state.
356+
// successRanges only -> non-match means rotated
357+
// rotatedRanges only -> non-match means live
358+
definitive = true
359+
if len(rotatedRanges) > 0 {
360+
result.Verified = true
361+
storeResponseBody(resp, result.ExtraData)
362+
}
363+
break
364+
}
365+
366+
// Both configured but neither matched -- try the next verifier.
367+
}
368+
369+
if rangesInEffect && !definitive {
370+
result.SetVerificationError(errors.New("verification response status code did not match any configured successRanges or rotatedRanges"))
329371
}
330372

331373
select {
@@ -336,6 +378,20 @@ func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string
336378
}
337379
}
338380

381+
const maxResponseLen = 200
382+
383+
func storeResponseBody(resp *http.Response, extraData map[string]string) {
384+
body, err := io.ReadAll(resp.Body)
385+
if err != nil {
386+
return
387+
}
388+
responseStr := string(body)
389+
if len(responseStr) > maxResponseLen {
390+
responseStr = responseStr[:maxResponseLen]
391+
}
392+
extraData["response"] = responseStr
393+
}
394+
339395
func (c *CustomRegexWebhook) Keywords() []string {
340396
return c.GetKeywords()
341397
}

0 commit comments

Comments
 (0)