Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0f6bb1a
Add whitelisting feature
jporzucek Sep 2, 2025
4c3476d
Move loading function to detectors pkg
jporzucek Sep 2, 2025
4f8af2f
fix tests
jporzucek Sep 2, 2025
9b2883a
Mask logged secret
jporzucek Sep 9, 2025
eacbb6c
Add support for multiline secrets
jporzucek Sep 10, 2025
fcd4251
Add example whitelist file
jporzucek Sep 10, 2025
eac3d7c
Merge branch 'main' into add-whitelist-feature
jporzucek Sep 10, 2025
4963cca
Change naming to allowlisted
jporzucek Sep 11, 2025
f58adf8
fix main.go naming changes
jporzucek Sep 11, 2025
8f8783f
Merge branch 'main' into add-whitelist-feature
nabeelalam Sep 11, 2025
96fbdd2
fix wrong assignment operator
jporzucek Sep 12, 2025
7010e58
add empty line to yaml if not exsit
jporzucek Sep 12, 2025
e5c2101
fix flag description
jporzucek Sep 12, 2025
8f85450
Fix test
jporzucek Sep 12, 2025
a526bde
Rename flag
jporzucek Sep 15, 2025
fa75aea
Merge branch 'main' into add-whitelist-feature
nabeelalam Sep 16, 2025
788fb7e
Use slices.DeleteFunc and pre-compile regexes
jporzucek Sep 18, 2025
0664b3c
Add allowlists to config
jporzucek Sep 22, 2025
011a859
Trim whitespace characters when matching secrets
jporzucek Sep 23, 2025
a5a459d
Merge branch 'main' into add-whitelist-feature
jporzucek Sep 23, 2025
f625db8
Change confusing var name
jporzucek Sep 23, 2025
b340d16
Update example config
jporzucek Sep 23, 2025
a384429
Merge branch 'main' into add-whitelist-feature
jporzucek Oct 9, 2025
4024f83
Merge branch 'main' into add-whitelist-feature
jporzucek Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions examples/allowlist.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
- description: "RSA test keys"
values:
- |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----
- description: "Demo API keys for documentation"
values:
- "demo_.*"
- "example-api-key-.*"
- "sk_test_.*"
- description: "Decommissioned secrets (rotated out)"
values:
- "old-prod-key-2023"
- "^legacy-.*-deprecated$"
- description: "Public certificates and well-known test tokens"
values:
- "^-----BEGIN CERTIFICATE-----"
- "password123"
- "token_1234567890abcdef"
10 changes: 10 additions & 0 deletions examples/generic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ detectors:
regex:
# borrowing the gitleaks generic-api-key regex
generic-api-key: "(?i)(?:key|api|token|secret|client|passwd|password|auth|access)(?:[0-9a-z\\-_\\t .]{0,20})(?:[\\s|']|[\\s|\"]){0,3}(?:=|>|:{1,3}=|\\|\\|:|<=|=>|:|\\?=)(?:'|\"|\\s|=|\\x60){0,5}([0-9a-z\\-_.=]{10,150})(?:['|\"|\\n|\\r|\\s|\\x60|;]|$)"
allowlists:
- description: "Test allowlist"
values:
- "https://user:password@example.com"
- |
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACm
- description: "Ignore AWS access keys"
values:
- "AKIA*"
21 changes: 21 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ var (
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()
filterEntropy = cli.Flag("filter-entropy", "Filter unverified results with Shannon entropy. Start with 3.0.").Float64()
allowlistSecretsFile = cli.Flag("allowlist-secrets-file", "Path to YAML file with secrets to allowlist. See examples/allowlist.yml for format.").String()
scanEntireChunk = cli.Flag("scan-entire-chunk", "Scan the entire chunk for secrets.").Hidden().Default("false").Bool()
compareDetectionStrategies = cli.Flag("compare-detection-strategies", "Compare different detection strategies for matching spans").Hidden().Default("false").Bool()
configFilename = cli.Flag("config", "Path to configuration file.").ExistingFile()
Expand Down Expand Up @@ -520,6 +521,25 @@ func run(state overseer.State) {

verificationCacheMetrics := verificationcache.InMemoryMetrics{}

// Load allowlisted secrets if specified
var allowlistedSecrets []detectors.AllowlistEntry
if *allowlistSecretsFile != "" {
allowlistedSecrets, err = detectors.LoadAllowlistedSecrets(*allowlistSecretsFile)
if err != nil {
logFatal(err, "failed to load allowlisted secrets")
}
}
allowListedSecrets := append(allowlistedSecrets, conf.Allowlists...)
compiledAllowlist := detectors.CompileAllowlistPatterns(allowListedSecrets)

logger.Info(
"loaded allowlisted secrets",
"exact_matches", len(compiledAllowlist.ExactMatches),
"regex_patterns", len(compiledAllowlist.CompiledRegexes),
"total", len(compiledAllowlist.ExactMatches)+len(compiledAllowlist.CompiledRegexes),
"file", *allowlistSecretsFile,
)

engConf := engine.Config{
Concurrency: *concurrency,
ConfiguredSources: conf.Sources,
Expand All @@ -536,6 +556,7 @@ func run(state overseer.State) {
Dispatcher: engine.NewPrinterDispatcher(printer),
FilterUnverified: *filterUnverified,
FilterEntropy: *filterEntropy,
AllowlistedSecrets: compiledAllowlist,
VerificationOverlap: *allowVerificationOverlap,
Results: parsedResults,
PrintAvgDetectorTime: *printAvgDetectorTime,
Expand Down
19 changes: 15 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import (

// Config holds user supplied configuration.
type Config struct {
Sources []sources.ConfiguredSource
Detectors []detectors.Detector
Sources []sources.ConfiguredSource
Detectors []detectors.Detector
Allowlists []detectors.AllowlistEntry
}

// Read parses a given filename into a Config.
Expand Down Expand Up @@ -66,9 +67,19 @@ func NewYAML(input []byte) (*Config, error) {
sourceConfigs = append(sourceConfigs, src)
}

// Convert allowlist entries to Go structs.
var allowlistConfigs []detectors.AllowlistEntry
for _, pbAllowlist := range inputYAML.Allowlists {
allowlistConfigs = append(allowlistConfigs, detectors.AllowlistEntry{
Description: pbAllowlist.GetDescription(),
Values: pbAllowlist.GetValues(),
})
}

return &Config{
Detectors: detectorConfigs,
Sources: sourceConfigs,
Detectors: detectorConfigs,
Sources: sourceConfigs,
Allowlists: allowlistConfigs,
}, nil
}

Expand Down
133 changes: 133 additions & 0 deletions pkg/detectors/falsepositives.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ package detectors
import (
_ "embed"
"fmt"
"io"
"math"
"os"
"regexp"
"slices"
"strings"
"unicode"
"unicode/utf8"

ahocorasick "github.com/BobuSumisu/aho-corasick"
"gopkg.in/yaml.v3"

"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/log"
)

var (
Expand All @@ -29,6 +35,19 @@ type CustomFalsePositiveChecker interface {
IsFalsePositive(result Result) (bool, string)
}

// AllowlistEntry represents an allowlist entry in the YAML config
type AllowlistEntry struct {
Description string `yaml:"description,omitempty"` // Optional description for the allowlist
Values []string `yaml:"values"` // List of secret patterns/regexes to allowlist
}

// CompiledAllowlist holds both exact string matches and compiled regex patterns for efficient matching
type CompiledAllowlist struct {
ExactMatches map[string]struct{} // For exact string matching (O(1) lookup)
CompiledRegexes []*regexp.Regexp // Pre-compiled regex patterns
RegexPatterns []string // Original regex patterns (for logging/debugging)
}

var (
filter *ahocorasick.Trie

Expand Down Expand Up @@ -199,3 +218,117 @@ func FilterKnownFalsePositives(ctx context.Context, detector Detector, results [

return filteredResults
}

// FilterAllowlistedSecrets filters out results that match allowlisted secrets.
// This allows users to specify known safe secrets that should not be reported.
// Supports regex patterns.
func FilterAllowlistedSecrets(ctx context.Context, results []Result, allowlist *CompiledAllowlist) []Result {
if allowlist == nil || (len(allowlist.ExactMatches) == 0 && len(allowlist.CompiledRegexes) == 0) {
return results
}

return slices.DeleteFunc(results, func(result Result) bool {
if len(result.Raw) == 0 {
return false // Keep results with empty Raw
}

// Check if the raw secret matches any allowlisted secret
rawSecret := string(result.Raw)
log.RedactGlobally(rawSecret)
if isAllowlisted, matchReason := isSecretAllowlisted(rawSecret, allowlist); isAllowlisted {
ctx.Logger().V(4).Info("Skipping result: allowlisted secret", "result", rawSecret, "reason", matchReason)
return true // Delete this result
}

// Also check RawV2 if present
if result.RawV2 != nil {
rawV2Secret := string(result.RawV2)
if isAllowlisted, matchReason := isSecretAllowlisted(rawV2Secret, allowlist); isAllowlisted {
ctx.Logger().V(4).Info("Skipping result: allowlisted secret", "result", rawV2Secret, "reason", matchReason)
return true // Delete this result
}
}

return false // Keep this result
})
}

// LoadAllowlistedSecrets loads secrets from a YAML file that should be allowlisted.
// The YAML format supports multiline secrets and includes optional descriptions.
// Returns a CompiledAllowlist with pre-compiled regex patterns for efficient matching.
func LoadAllowlistedSecrets(yamlFile string) ([]AllowlistEntry, error) {
file, err := os.Open(yamlFile)
if err != nil {
return nil, fmt.Errorf("failed to open allowlist file: %w", err)
}
defer file.Close()

// Read the entire file content
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read allowlist file: %w", err)
}

var allowList []AllowlistEntry
if err := yaml.Unmarshal(content, &allowList); err != nil {
return nil, fmt.Errorf("failed to parse YAML allowlist file: %w", err)
}

return allowList, nil
}

// CompileAllowlistPatterns compiles a list of patterns into a CompiledAllowlist.
// All patterns are first attempted to be compiled as regex. If compilation fails,
// they are treated as exact string matches.
func CompileAllowlistPatterns(allowList []AllowlistEntry) *CompiledAllowlist {
compiledAllowlist := &CompiledAllowlist{
ExactMatches: make(map[string]struct{}, 0),
CompiledRegexes: make([]*regexp.Regexp, 0),
RegexPatterns: make([]string, 0),
}

for _, entry := range allowList {
for _, pattern := range entry.Values {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue // Skip empty patterns
}

// Always try to compile as regex first
if compiledRegex, err := regexp.Compile(pattern); err == nil {
// Successfully compiled as regex
compiledAllowlist.CompiledRegexes = append(compiledAllowlist.CompiledRegexes, compiledRegex)
compiledAllowlist.RegexPatterns = append(compiledAllowlist.RegexPatterns, pattern)
} else {
// Invalid regex, treat as exact string match
compiledAllowlist.ExactMatches[pattern] = struct{}{}
}
}
}

return compiledAllowlist
}

// isSecretAllowlisted checks if a secret matches any allowlisted pattern (exact string or regex)
func isSecretAllowlisted(secret string, allowlist *CompiledAllowlist) (bool, string) {
if allowlist == nil {
return false, ""
}

// Trim all whitespace (spaces, tabs, newlines, carriage returns) from the secret
secret = strings.TrimSpace(secret)

// First, try exact string matching for performance (O(1) lookup)
if _, isAllowlisted := allowlist.ExactMatches[secret]; isAllowlisted {
return true, "exact match"
}

// Try pre-compiled regex patterns
for i, compiledRegex := range allowlist.CompiledRegexes {
if compiledRegex.MatchString(secret) {
return true, "regex match: " + allowlist.RegexPatterns[i]
}
}

return false, ""
}
Loading