Skip to content
3 changes: 3 additions & 0 deletions docs/man/trufflehog.1
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ Specifies which type(s) of results to output: verified (confirmed valid by API),
\fB--no-color\fR
Disable colorized output
.TP
\fB--header=HEADER\fR
Custom HTTP header to add to detector verification requests; repeatable. Format 'Name: value'. WARNING: headers are sent to every verification endpoint; do not use this for credentials scoped to a specific target.
.TP
\fB--allow-verification-overlap\fR
Allow verification of similar credentials across detectors
.TP
Expand Down
28 changes: 28 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ var (
results = cli.Flag("results", "Specifies which type(s) of results to output: verified (confirmed valid by API), unknown (verification failed due to error), unverified (detected but not verified), filtered_unverified (unverified but would have been filtered out). Defaults to verified,unverified,unknown.").String()
noColor = cli.Flag("no-color", "Disable colorized output").Bool()
noColour = cli.Flag("no-colour", "Alias for --no-color").Hidden().Bool()
customHeaders = cli.Flag("header", "Custom HTTP header to add to detector verification requests; repeatable. Format 'Name: value'. WARNING: headers are sent to every verification endpoint; do not use this for credentials scoped to a specific target.").Strings()

allowVerificationOverlap = cli.Flag("allow-verification-overlap", "Allow verification of similar credentials across detectors").Bool()
filterUnverified = cli.Flag("filter-unverified", "Only output first unverified result per chunk per detector if there are more than one results.").Bool()
Expand Down Expand Up @@ -512,6 +513,33 @@ func run(state overseer.State, logSync func() error) {
feature.UserAgentSuffix.Store(*userAgentSuffix)
}

// Parse and set any custom headers specified via --header. Malformed
// entries are fatal at startup so misconfiguration cannot silently fail
// every outbound request.
if len(*customHeaders) > 0 {
hdr, errs := common.ParseHeaders(*customHeaders)
if len(errs) > 0 {
for _, err := range errs {
logger.Error(err, "invalid --header")
}
logFatal(fmt.Errorf("%d invalid --header value(s)", len(errs)), "refusing to start with malformed custom headers")
}
if len(hdr) > 0 {
feature.CustomHeaders.Store(hdr)
logger.V(2).Info("custom headers configured", "count", len(*customHeaders))
// Wrap http.DefaultTransport so any detector using http.DefaultClient
// or a transport-less http.Client picks up --header automatically.
// This is the architectural fix that avoids per-detector edits: any
// HTTP path that falls through to the stdlib default — including
// x/oauth2 token sources whose Base is nil — now goes through
// CustomTransport. Done only when --header is set so no-flag users
// see no behavior change. SDK clients that build their own complete
// transport stack (notably aws-sdk-go-v2) bypass this and require
// SDK-level injection; see common/aws_middleware.go.
http.DefaultTransport = common.NewCustomTransport(http.DefaultTransport)
}
}

// OSS Default APK handling on
feature.EnableAPKHandler.Store(true)

Expand Down
46 changes: 46 additions & 0 deletions pkg/common/header_parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package common

import (
"fmt"
"net/http"
"strings"

"golang.org/x/net/http/httpguts"
)

// ParseHeaders parses raw header entries from the --header CLI flag in
// "Name: value" form. It returns an http.Header containing all valid entries
// and a slice of per-entry errors for any that failed validation.
//
// Empty values are permitted (RFC 7230 allows empty header values); empty
// names are rejected. Names and values are validated with
// httpguts.ValidHeaderFieldName and ValidHeaderFieldValue so malformed
// headers are reported at parse time rather than silently failing every
// outbound request.
func ParseHeaders(raw []string) (http.Header, []error) {
hdr := http.Header{}
var errs []error
for _, entry := range raw {
idx := strings.Index(entry, ":")
if idx == -1 {
errs = append(errs, fmt.Errorf("invalid --header %q: expected 'Name: value'", entry))
continue
}
key := strings.TrimSpace(entry[:idx])
val := strings.TrimSpace(entry[idx+1:])
if key == "" {
errs = append(errs, fmt.Errorf("invalid --header %q: empty header name", entry))
continue
}
if !httpguts.ValidHeaderFieldName(key) {
errs = append(errs, fmt.Errorf("invalid --header %q: malformed header name %q", entry, key))
continue
}
if !httpguts.ValidHeaderFieldValue(val) {
errs = append(errs, fmt.Errorf("invalid --header %q: malformed header value", entry))
continue
}
hdr.Add(key, val)
}
return hdr, errs
}
128 changes: 128 additions & 0 deletions pkg/common/header_parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package common

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseHeaders(t *testing.T) {
tests := []struct {
name string
input []string
want map[string][]string
wantErrs int
}{
{
name: "no input",
input: nil,
want: map[string][]string{},
wantErrs: 0,
},
{
name: "simple Name: value",
input: []string{"X-Scanner-Id: abc"},
want: map[string][]string{"X-Scanner-Id": {"abc"}},
wantErrs: 0,
},
{
name: "no whitespace after colon",
input: []string{"X-Foo:bar"},
want: map[string][]string{"X-Foo": {"bar"}},
wantErrs: 0,
},
{
name: "extra whitespace trimmed",
input: []string{" X-Foo : bar "},
want: map[string][]string{"X-Foo": {"bar"}},
wantErrs: 0,
},
{
name: "value containing colons preserved (URL)",
input: []string{"X-Callback: https://example.com/cb"},
want: map[string][]string{"X-Callback": {"https://example.com/cb"}},
wantErrs: 0,
},
{
name: "empty value allowed (RFC 7230)",
input: []string{"X-Empty:"},
want: map[string][]string{"X-Empty": {""}},
wantErrs: 0,
},
{
name: "multiple values for same name",
input: []string{"X-Foo: a", "X-Foo: b"},
want: map[string][]string{"X-Foo": {"a", "b"}},
wantErrs: 0,
},
{
name: "no separator rejected",
input: []string{"BadHeader"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "empty name rejected",
input: []string{": value"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "whitespace-only name rejected",
input: []string{" : value"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "name with space rejected",
input: []string{"Bad Name: value"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "name with CRLF rejected",
input: []string{"X-Foo\r\nX-Bar: smuggled"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "value with CR rejected",
input: []string{"X-Foo: bad\rvalue"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "value with LF rejected",
input: []string{"X-Foo: bad\nvalue"},
want: map[string][]string{},
wantErrs: 1,
},
{
name: "valid + invalid in same call: both reported",
input: []string{"X-Good: ok", "BadHeader", "X-Also-Good: yes"},
want: map[string][]string{
"X-Good": {"ok"},
"X-Also-Good": {"yes"},
},
wantErrs: 1,
},
{
name: "equals is NOT a separator (legacy support dropped)",
input: []string{"X-Foo=bar"},
want: map[string][]string{},
wantErrs: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
hdr, errs := ParseHeaders(tt.input)
assert.Len(t, errs, tt.wantErrs, "unexpected error count")
assert.Equal(t, len(tt.want), len(hdr), "unexpected header count")
for k, vals := range tt.want {
assert.Equal(t, vals, hdr.Values(k), "values for %s", k)
}
})
}
}
21 changes: 21 additions & 0 deletions pkg/common/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,29 @@ func UserAgent() string {
return "TruffleHog"
}

// ApplyCustomHeaders applies any globally configured custom headers to req.
// User-supplied headers fully override existing values for the same name: the
// first value Sets (replacing anything previously assigned, e.g. the default
// User-Agent), and any further values for that name Add. This lets a user
// pass --header 'User-Agent: MyScanner' and actually replace the default
// rather than ending up with two User-Agent values on the wire.
func ApplyCustomHeaders(req *http.Request) {
if hdr := feature.CustomHeaders.Load(); hdr != nil {
for k, vals := range hdr {
for i, v := range vals {
if i == 0 {
req.Header.Set(k, v)
} else {
req.Header.Add(k, v)
}
}
}
}
}

func (t *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("User-Agent", UserAgent())
ApplyCustomHeaders(req)
return t.T.RoundTrip(req)
}

Expand Down
109 changes: 109 additions & 0 deletions pkg/common/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,117 @@ import (
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
)

// resetCustomHeaders clears the global CustomHeaders state and registers a
// cleanup that restores it. Tests that mutate feature.CustomHeaders must use
// this helper and must not run in parallel with each other since they share
// global state.
func resetCustomHeaders(t *testing.T) {
t.Helper()
feature.CustomHeaders.Store(http.Header{})
t.Cleanup(func() { feature.CustomHeaders.Store(http.Header{}) })
}

func TestApplyCustomHeaders(t *testing.T) {
t.Run("no headers configured leaves request untouched", func(t *testing.T) {
resetCustomHeaders(t)
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "OriginalUA")

ApplyCustomHeaders(req)

assert.Equal(t, "OriginalUA", req.Header.Get("User-Agent"))
assert.Len(t, req.Header.Values("User-Agent"), 1)
})

t.Run("single user value replaces existing default", func(t *testing.T) {
resetCustomHeaders(t)
feature.CustomHeaders.Store(http.Header{
"User-Agent": []string{"MyScanner"},
})
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "TruffleHog")

ApplyCustomHeaders(req)

assert.Equal(t, []string{"MyScanner"}, req.Header.Values("User-Agent"),
"first user-supplied value should Set, not Add, so the default is replaced")
})

t.Run("multiple values: first sets, rest add", func(t *testing.T) {
resetCustomHeaders(t)
feature.CustomHeaders.Store(http.Header{
"X-Foo": []string{"a", "b", "c"},
})
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
require.NoError(t, err)
req.Header.Set("X-Foo", "preexisting")

ApplyCustomHeaders(req)

assert.Equal(t, []string{"a", "b", "c"}, req.Header.Values("X-Foo"),
"preexisting value should be replaced by first user value, then b and c appended")
})

t.Run("Load returns a clone: caller mutation does not race", func(t *testing.T) {
resetCustomHeaders(t)
feature.CustomHeaders.Store(http.Header{
"X-Foo": []string{"original"},
})

loaded := feature.CustomHeaders.Load()
loaded.Set("X-Foo", "mutated-by-caller")

// The next Load must not see the caller's mutation.
fresh := feature.CustomHeaders.Load()
assert.Equal(t, "original", fresh.Get("X-Foo"),
"AtomicHeader.Load must return a defensive copy")
})
}

// TestCustomTransportSendsCustomHeaders is an end-to-end test that sets
// CustomHeaders, dispatches a request through CustomTransport, and asserts
// the headers arrive at the server. This guards against future changes that
// might bypass ApplyCustomHeaders in the transport chain.
func TestCustomTransportSendsCustomHeaders(t *testing.T) {
resetCustomHeaders(t)

feature.CustomHeaders.Store(http.Header{
"X-Scanner-Id": []string{"test-scanner"},
"X-Multi": []string{"one", "two"},
"User-Agent": []string{"OverrideUA"},
"X-Empty-Value": []string{""},
})

var captured http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client := &http.Client{
Transport: NewCustomTransport(nil),
Timeout: 5 * time.Second,
}
resp, err := client.Get(server.URL)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, "test-scanner", captured.Get("X-Scanner-Id"))
assert.Equal(t, []string{"one", "two"}, captured.Values("X-Multi"))
assert.Equal(t, []string{"OverrideUA"}, captured.Values("User-Agent"),
"user-supplied User-Agent should fully replace the default, not stack")
require.Contains(t, captured, "X-Empty-Value")
assert.Equal(t, []string{""}, captured.Values("X-Empty-Value"),
"empty values are valid per RFC 7230 and must reach the wire")
}

func TestRetryableHTTPClientCheckRetry(t *testing.T) {
testCases := []struct {
name string
Expand Down
Loading
Loading