mirror of
https://github.com/replicatedhq/troubleshoot.git
synced 2026-04-15 07:16:34 +00:00
Core tokenization functionality with minimal file changes: ✅ Core Features: - Intelligent tokenization engine (tokenizer.go) - Context-aware secret classification (PASSWORD, APIKEY, DATABASE, etc.) - Cross-file correlation with deterministic HMAC-SHA256 tokens - Optional encrypted mapping for token→original value resolution ✅ Integration: - CLI flags: --tokenize, --redaction-map, --encrypt-redaction-map - Updated all redactor types: literal, single-line, multi-line, YAML - Support bundle integration with auto-upload compatibility - Backward compatibility: preserves ***HIDDEN*** when disabled ✅ Production Ready: - Only 11 essential files (vs 31 in original PR) - No excessive test files or documentation - Clean build, all functionality verified - Maintains existing redaction behavior by default Token format: ***TOKEN_<TYPE>_<HASH>*** (e.g., ***TOKEN_PASSWORD_A1B2C3***)
977 lines
30 KiB
Go
977 lines
30 KiB
Go
package redact
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// TokenPrefix represents different types of secrets for token generation
|
|
type TokenPrefix string
|
|
|
|
const (
|
|
TokenPrefixPassword TokenPrefix = "PASSWORD"
|
|
TokenPrefixAPIKey TokenPrefix = "APIKEY"
|
|
TokenPrefixDatabase TokenPrefix = "DATABASE"
|
|
TokenPrefixEmail TokenPrefix = "EMAIL"
|
|
TokenPrefixIP TokenPrefix = "IP"
|
|
TokenPrefixToken TokenPrefix = "TOKEN"
|
|
TokenPrefixSecret TokenPrefix = "SECRET"
|
|
TokenPrefixKey TokenPrefix = "KEY"
|
|
TokenPrefixCredential TokenPrefix = "CREDENTIAL"
|
|
TokenPrefixAuth TokenPrefix = "AUTH"
|
|
TokenPrefixGeneric TokenPrefix = "GENERIC"
|
|
)
|
|
|
|
// TokenizerConfig holds configuration for the tokenizer
|
|
type TokenizerConfig struct {
|
|
// Enable tokenization (defaults to checking TROUBLESHOOT_TOKENIZATION env var)
|
|
Enabled bool
|
|
|
|
// Salt for deterministic token generation per bundle
|
|
Salt []byte
|
|
|
|
// Default token prefix when type cannot be determined
|
|
DefaultPrefix TokenPrefix
|
|
|
|
// Token format template (must include %s for prefix and %s for hash)
|
|
TokenFormat string
|
|
|
|
// Hash length in characters (default 6)
|
|
HashLength int
|
|
}
|
|
|
|
// Tokenizer handles deterministic secret tokenization
|
|
type Tokenizer struct {
|
|
config TokenizerConfig
|
|
tokenMap map[string]string // secret value -> token
|
|
reverseMap map[string]string // token -> secret value (for debugging/mapping)
|
|
mutex sync.RWMutex
|
|
|
|
// Secret type detection patterns
|
|
typePatterns map[TokenPrefix]*regexp.Regexp
|
|
|
|
// Phase 2: Cross-File Correlation fields
|
|
bundleID string // unique bundle identifier
|
|
secretRefs map[string][]string // token -> list of file paths
|
|
duplicateGroups map[string]*DuplicateGroup // secretHash -> DuplicateGroup
|
|
correlations []CorrelationGroup // detected correlations
|
|
fileStats map[string]*FileStats // filePath -> FileStats
|
|
cacheStats CacheStats // performance statistics
|
|
normalizedSecrets map[string]string // normalized secret -> original secret
|
|
secretHashes map[string]string // secret value -> hash for deduplication
|
|
}
|
|
|
|
// RedactionMap represents the mapping between tokens and original values
|
|
type RedactionMap struct {
|
|
Tokens map[string]string `json:"tokens"` // token -> original value
|
|
Stats RedactionStats `json:"stats"` // redaction statistics
|
|
Timestamp time.Time `json:"timestamp"` // when redaction was performed
|
|
Profile string `json:"profile"` // profile used
|
|
BundleID string `json:"bundleId"` // unique bundle identifier
|
|
SecretRefs map[string][]string `json:"secretRefs"` // token -> list of file paths where found
|
|
Duplicates []DuplicateGroup `json:"duplicates"` // groups of identical secrets
|
|
Correlations []CorrelationGroup `json:"correlations"` // correlated secret patterns
|
|
EncryptionKey []byte `json:"-"` // encryption key (not serialized)
|
|
IsEncrypted bool `json:"isEncrypted"` // whether the mapping is encrypted
|
|
}
|
|
|
|
// RedactionStats contains statistics about the redaction process
|
|
type RedactionStats struct {
|
|
TotalSecrets int `json:"totalSecrets"`
|
|
UniqueSecrets int `json:"uniqueSecrets"`
|
|
TokensGenerated int `json:"tokensGenerated"`
|
|
SecretsByType map[string]int `json:"secretsByType"`
|
|
ProcessingTimeMs int64 `json:"processingTimeMs"`
|
|
FilesCovered int `json:"filesCovered"`
|
|
DuplicateCount int `json:"duplicateCount"`
|
|
CorrelationCount int `json:"correlationCount"`
|
|
NormalizationHits int `json:"normalizationHits"`
|
|
CacheHits int `json:"cacheHits"`
|
|
CacheMisses int `json:"cacheMisses"`
|
|
FileCoverage map[string]FileStats `json:"fileCoverage"`
|
|
}
|
|
|
|
// FileStats tracks statistics per file
|
|
type FileStats struct {
|
|
FilePath string `json:"filePath"`
|
|
SecretsFound int `json:"secretsFound"`
|
|
TokensUsed int `json:"tokensUsed"`
|
|
SecretTypes map[string]int `json:"secretTypes"`
|
|
ProcessedAt time.Time `json:"processedAt"`
|
|
}
|
|
|
|
// DuplicateGroup represents a group of identical secrets found in different locations
|
|
type DuplicateGroup struct {
|
|
SecretHash string `json:"secretHash"` // hash of the normalized secret
|
|
Token string `json:"token"` // the token used for this secret
|
|
SecretType string `json:"secretType"` // classified type of the secret
|
|
Locations []string `json:"locations"` // file paths where this secret was found
|
|
Count int `json:"count"` // total occurrences
|
|
FirstSeen time.Time `json:"firstSeen"` // when first detected
|
|
LastSeen time.Time `json:"lastSeen"` // when last detected
|
|
}
|
|
|
|
// CorrelationGroup represents correlated secret patterns across files
|
|
type CorrelationGroup struct {
|
|
Pattern string `json:"pattern"` // correlation pattern identifier
|
|
Description string `json:"description"` // human-readable description
|
|
Tokens []string `json:"tokens"` // tokens involved in correlation
|
|
Files []string `json:"files"` // files where correlation was found
|
|
Confidence float64 `json:"confidence"` // confidence score (0.0-1.0)
|
|
DetectedAt time.Time `json:"detectedAt"` // when correlation was detected
|
|
}
|
|
|
|
// CacheStats tracks tokenizer cache performance
|
|
type CacheStats struct {
|
|
Hits int64 `json:"hits"` // cache hits
|
|
Misses int64 `json:"misses"` // cache misses
|
|
Total int64 `json:"total"` // total lookups
|
|
}
|
|
|
|
var (
|
|
// Global tokenizer instance
|
|
globalTokenizer *Tokenizer
|
|
tokenizerOnce sync.Once
|
|
)
|
|
|
|
// NewTokenizer creates a new tokenizer with the given configuration
|
|
func NewTokenizer(config TokenizerConfig) *Tokenizer {
|
|
if config.TokenFormat == "" {
|
|
config.TokenFormat = "***TOKEN_%s_%s***"
|
|
}
|
|
if config.HashLength == 0 {
|
|
config.HashLength = 6
|
|
}
|
|
if config.DefaultPrefix == "" {
|
|
config.DefaultPrefix = TokenPrefixGeneric
|
|
}
|
|
|
|
// Generate salt if not provided
|
|
if len(config.Salt) == 0 {
|
|
config.Salt = make([]byte, 32)
|
|
if _, err := rand.Read(config.Salt); err != nil {
|
|
// Fallback to time-based salt if crypto rand fails
|
|
timeStr := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
config.Salt = []byte(timeStr)
|
|
}
|
|
}
|
|
|
|
// Generate bundle ID if not provided
|
|
bundleID := fmt.Sprintf("bundle_%d_%s", time.Now().UnixNano(), hex.EncodeToString(config.Salt[:8]))
|
|
|
|
tokenizer := &Tokenizer{
|
|
config: config,
|
|
tokenMap: make(map[string]string),
|
|
reverseMap: make(map[string]string),
|
|
typePatterns: make(map[TokenPrefix]*regexp.Regexp),
|
|
bundleID: bundleID,
|
|
secretRefs: make(map[string][]string),
|
|
duplicateGroups: make(map[string]*DuplicateGroup),
|
|
correlations: make([]CorrelationGroup, 0),
|
|
fileStats: make(map[string]*FileStats),
|
|
cacheStats: CacheStats{},
|
|
normalizedSecrets: make(map[string]string),
|
|
secretHashes: make(map[string]string),
|
|
}
|
|
|
|
// Initialize secret type detection patterns
|
|
tokenizer.initTypePatterns()
|
|
|
|
return tokenizer
|
|
}
|
|
|
|
// GetGlobalTokenizer returns the global tokenizer instance
|
|
func GetGlobalTokenizer() *Tokenizer {
|
|
tokenizerOnce.Do(func() {
|
|
globalTokenizer = NewTokenizer(TokenizerConfig{
|
|
Enabled: false, // Will be set explicitly by calling code
|
|
})
|
|
})
|
|
|
|
return globalTokenizer
|
|
}
|
|
|
|
// EnableTokenization enables tokenization on the global tokenizer
|
|
func EnableTokenization() {
|
|
globalTokenizer := GetGlobalTokenizer()
|
|
globalTokenizer.config.Enabled = true
|
|
}
|
|
|
|
// DisableTokenization disables tokenization on the global tokenizer
|
|
func DisableTokenization() {
|
|
globalTokenizer := GetGlobalTokenizer()
|
|
globalTokenizer.config.Enabled = false
|
|
}
|
|
|
|
// IsEnabled returns whether tokenization is enabled
|
|
func (t *Tokenizer) IsEnabled() bool {
|
|
return t.config.Enabled
|
|
}
|
|
|
|
// initTypePatterns initializes regex patterns for secret type detection
|
|
func (t *Tokenizer) initTypePatterns() {
|
|
patterns := map[TokenPrefix]string{
|
|
TokenPrefixPassword: `(?i)password|passwd|pwd`,
|
|
TokenPrefixAPIKey: `(?i)api.?key|apikey|access.?key`,
|
|
TokenPrefixDatabase: `(?i)database|db.?(url|uri|host|pass|connection)`,
|
|
TokenPrefixEmail: `(?i)[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`,
|
|
TokenPrefixIP: `(?i)\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`,
|
|
TokenPrefixToken: `(?i)token|bearer|jwt|oauth`,
|
|
TokenPrefixSecret: `(?i)secret|private.?key`,
|
|
TokenPrefixCredential: `(?i)credential|cred|auth`,
|
|
TokenPrefixKey: `(?i)key|cert|certificate`,
|
|
}
|
|
|
|
for prefix, pattern := range patterns {
|
|
if compiled, err := regexp.Compile(pattern); err == nil {
|
|
t.typePatterns[prefix] = compiled
|
|
}
|
|
}
|
|
}
|
|
|
|
// classifySecret determines the appropriate token prefix for a secret value
|
|
func (t *Tokenizer) classifySecret(context, value string) TokenPrefix {
|
|
contextLower := strings.ToLower(context)
|
|
valueLower := strings.ToLower(value)
|
|
|
|
// Check context first, with specific patterns having priority
|
|
// Order matters here - more specific patterns should be checked first
|
|
specificPrefixes := []TokenPrefix{
|
|
TokenPrefixAPIKey,
|
|
TokenPrefixPassword,
|
|
TokenPrefixDatabase,
|
|
TokenPrefixCredential,
|
|
TokenPrefixSecret,
|
|
TokenPrefixToken,
|
|
TokenPrefixKey, // More general, check last
|
|
}
|
|
|
|
for _, prefix := range specificPrefixes {
|
|
if pattern, exists := t.typePatterns[prefix]; exists {
|
|
if pattern.MatchString(contextLower) {
|
|
return prefix
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check value patterns for specific formats (email, IP, etc.)
|
|
if pattern, exists := t.typePatterns[TokenPrefixEmail]; exists && pattern.MatchString(value) {
|
|
return TokenPrefixEmail
|
|
}
|
|
if pattern, exists := t.typePatterns[TokenPrefixIP]; exists && pattern.MatchString(value) {
|
|
return TokenPrefixIP
|
|
}
|
|
|
|
// Check value content for common secret indicators (same priority order)
|
|
for _, prefix := range specificPrefixes {
|
|
prefixLower := strings.ToLower(string(prefix))
|
|
if strings.Contains(valueLower, prefixLower) {
|
|
return prefix
|
|
}
|
|
}
|
|
|
|
return t.config.DefaultPrefix
|
|
}
|
|
|
|
// generateToken creates a deterministic token for a given secret value
|
|
func (t *Tokenizer) generateToken(value, context string) string {
|
|
// Classify the secret type
|
|
prefix := t.classifySecret(context, value)
|
|
|
|
// Generate deterministic hash using HMAC-SHA256
|
|
h := hmac.New(sha256.New, t.config.Salt)
|
|
h.Write([]byte(value))
|
|
h.Write([]byte(context)) // Include context for better uniqueness
|
|
hash := h.Sum(nil)
|
|
|
|
// Convert to hex and truncate to desired length
|
|
hashStr := hex.EncodeToString(hash)
|
|
if len(hashStr) > t.config.HashLength {
|
|
hashStr = hashStr[:t.config.HashLength]
|
|
}
|
|
|
|
// Generate token with collision detection
|
|
baseToken := fmt.Sprintf(t.config.TokenFormat, string(prefix), strings.ToUpper(hashStr))
|
|
|
|
// Check for collisions and resolve them
|
|
token := t.resolveCollision(baseToken, value)
|
|
|
|
return token
|
|
}
|
|
|
|
// resolveCollision handles token collisions by appending a counter
|
|
func (t *Tokenizer) resolveCollision(baseToken, value string) string {
|
|
// Check for collision without lock first
|
|
existingValue, exists := t.reverseMap[baseToken]
|
|
|
|
// No collision
|
|
if !exists || existingValue == value {
|
|
return baseToken
|
|
}
|
|
|
|
// Collision detected, try up to 100 variations
|
|
for counter := 1; counter <= 100; counter++ {
|
|
newToken := fmt.Sprintf("%s_%d", baseToken, counter)
|
|
|
|
existingValue, exists = t.reverseMap[newToken]
|
|
if !exists || existingValue == value {
|
|
return newToken
|
|
}
|
|
}
|
|
|
|
// If we still have collisions after 100 tries, use timestamp
|
|
timestamp := time.Now().UnixNano()
|
|
// Insert counter before the final *** to match ValidateToken regex
|
|
if strings.HasSuffix(baseToken, "***") {
|
|
base := strings.TrimSuffix(baseToken, "***")
|
|
return fmt.Sprintf("%s_%d***", base, timestamp%10000)
|
|
}
|
|
return fmt.Sprintf("%s_%d", baseToken, timestamp%10000)
|
|
}
|
|
|
|
// TokenizeValue generates or retrieves a token for a secret value
|
|
func (t *Tokenizer) TokenizeValue(value, context string) string {
|
|
return t.TokenizeValueWithPath(value, context, "")
|
|
}
|
|
|
|
// TokenizeValueWithPath generates or retrieves a token for a secret value with file path tracking
|
|
func (t *Tokenizer) TokenizeValueWithPath(value, context, filePath string) string {
|
|
if !t.config.Enabled || value == "" {
|
|
return MASK_TEXT // Fallback to original behavior
|
|
}
|
|
|
|
t.mutex.Lock()
|
|
defer t.mutex.Unlock()
|
|
|
|
// Normalize the secret value for better correlation
|
|
normalizedValue := t.normalizeSecret(value)
|
|
|
|
// Update cache statistics
|
|
t.cacheStats.Total++
|
|
|
|
// Check if we already have a token for this normalized value
|
|
if existing, exists := t.tokenMap[normalizedValue]; exists {
|
|
t.cacheStats.Hits++
|
|
|
|
// Track this usage even if token already exists
|
|
if filePath != "" {
|
|
t.addSecretReference(existing, filePath)
|
|
|
|
// Get secret type for tracking
|
|
secretType := string(t.classifySecret(context, value))
|
|
t.updateFileStats(filePath, secretType)
|
|
|
|
// Update duplicate tracking
|
|
secretHash := t.generateSecretHash(normalizedValue)
|
|
t.trackDuplicateSecret(secretHash, existing, secretType, filePath, normalizedValue)
|
|
}
|
|
|
|
return existing
|
|
}
|
|
|
|
t.cacheStats.Misses++
|
|
|
|
// Generate new token
|
|
token := t.generateToken(normalizedValue, context)
|
|
|
|
// Store in both directions (use normalized value as key)
|
|
t.tokenMap[normalizedValue] = token
|
|
t.reverseMap[token] = value // Store original value for mapping
|
|
|
|
// Track secret hash for deduplication
|
|
secretHash := t.generateSecretHash(normalizedValue)
|
|
t.secretHashes[normalizedValue] = secretHash
|
|
|
|
// Track file reference and stats if path provided
|
|
if filePath != "" {
|
|
t.addSecretReference(token, filePath)
|
|
|
|
// Get secret type for tracking
|
|
secretType := string(t.classifySecret(context, value))
|
|
t.updateFileStats(filePath, secretType)
|
|
|
|
// Track as duplicate (even first occurrence)
|
|
t.trackDuplicateSecret(secretHash, token, secretType, filePath, normalizedValue)
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
// GetRedactionMap returns the current redaction map
|
|
func (t *Tokenizer) GetRedactionMap(profile string) RedactionMap {
|
|
t.mutex.Lock()
|
|
defer t.mutex.Unlock()
|
|
|
|
// Analyze correlations before generating the map
|
|
t.analyzeCorrelations()
|
|
|
|
// Create stats
|
|
secretsByType := make(map[string]int)
|
|
for token := range t.reverseMap {
|
|
// Extract type from token format
|
|
if parts := strings.Split(token, "_"); len(parts) >= 2 {
|
|
// Expected format: ***TOKEN_TYPE_HASH***
|
|
if len(parts) >= 3 && strings.HasPrefix(token, "***TOKEN_") {
|
|
tokenType := parts[2] // Extract TYPE part
|
|
secretsByType[tokenType]++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count duplicates and correlations
|
|
duplicateCount := 0
|
|
for _, group := range t.duplicateGroups {
|
|
if group.Count > 1 {
|
|
duplicateCount++
|
|
}
|
|
}
|
|
|
|
// Copy file coverage
|
|
fileCoverage := make(map[string]FileStats)
|
|
for path, stats := range t.fileStats {
|
|
if stats != nil {
|
|
fileCoverage[path] = *stats
|
|
}
|
|
}
|
|
|
|
// Convert duplicate groups to slice
|
|
duplicates := make([]DuplicateGroup, 0, len(t.duplicateGroups))
|
|
for _, group := range t.duplicateGroups {
|
|
if group != nil {
|
|
duplicates = append(duplicates, *group)
|
|
}
|
|
}
|
|
|
|
stats := RedactionStats{
|
|
TotalSecrets: len(t.tokenMap),
|
|
UniqueSecrets: len(t.tokenMap),
|
|
TokensGenerated: len(t.reverseMap),
|
|
SecretsByType: secretsByType,
|
|
ProcessingTimeMs: 0, // Would be populated by caller
|
|
FilesCovered: len(t.fileStats),
|
|
DuplicateCount: duplicateCount,
|
|
CorrelationCount: len(t.correlations),
|
|
NormalizationHits: len(t.normalizedSecrets),
|
|
CacheHits: int(t.cacheStats.Hits),
|
|
CacheMisses: int(t.cacheStats.Misses),
|
|
FileCoverage: fileCoverage,
|
|
}
|
|
|
|
return RedactionMap{
|
|
Tokens: t.reverseMap,
|
|
Stats: stats,
|
|
Timestamp: time.Now(),
|
|
Profile: profile,
|
|
BundleID: t.bundleID,
|
|
SecretRefs: t.secretRefs,
|
|
Duplicates: duplicates,
|
|
Correlations: t.correlations,
|
|
IsEncrypted: false, // Will be set when encryption is applied
|
|
}
|
|
}
|
|
|
|
// ValidateToken checks if a token matches the expected format
|
|
func (t *Tokenizer) ValidateToken(token string) bool {
|
|
// Basic format validation - should match ***TOKEN_PREFIX_HASH***
|
|
pattern := `^\*\*\*TOKEN_[A-Z]+_[A-F0-9]+(\*\*\*|_\d+\*\*\*)$`
|
|
matched, err := regexp.MatchString(pattern, token)
|
|
return err == nil && matched
|
|
}
|
|
|
|
// Reset clears all tokens and mappings (useful for testing)
|
|
func (t *Tokenizer) Reset() {
|
|
t.mutex.Lock()
|
|
defer t.mutex.Unlock()
|
|
|
|
t.tokenMap = make(map[string]string)
|
|
t.reverseMap = make(map[string]string)
|
|
t.secretRefs = make(map[string][]string)
|
|
t.duplicateGroups = make(map[string]*DuplicateGroup)
|
|
t.correlations = make([]CorrelationGroup, 0)
|
|
t.fileStats = make(map[string]*FileStats)
|
|
t.cacheStats = CacheStats{}
|
|
t.normalizedSecrets = make(map[string]string)
|
|
t.secretHashes = make(map[string]string)
|
|
}
|
|
|
|
// GetTokenCount returns the number of tokens generated
|
|
func (t *Tokenizer) GetTokenCount() int {
|
|
t.mutex.RLock()
|
|
defer t.mutex.RUnlock()
|
|
|
|
return len(t.tokenMap)
|
|
}
|
|
|
|
// ResetGlobalTokenizer resets the global tokenizer instance (useful for testing)
|
|
func ResetGlobalTokenizer() {
|
|
globalTokenizer = nil
|
|
tokenizerOnce = sync.Once{}
|
|
}
|
|
|
|
// analyzeCorrelations detects patterns and correlations across secrets
|
|
func (t *Tokenizer) analyzeCorrelations() {
|
|
// Detect common correlation patterns
|
|
correlations := make([]CorrelationGroup, 0)
|
|
|
|
// Pattern 1: Database connection components (host, user, password, database)
|
|
dbTokens := make([]string, 0)
|
|
dbFiles := make([]string, 0)
|
|
|
|
for token, files := range t.secretRefs {
|
|
// Check if token looks like database-related
|
|
if strings.Contains(token, "DATABASE") || strings.Contains(token, "PASSWORD") {
|
|
dbTokens = append(dbTokens, token)
|
|
for _, file := range files {
|
|
// Add file if not already present
|
|
found := false
|
|
for _, existing := range dbFiles {
|
|
if existing == file {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
dbFiles = append(dbFiles, file)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(dbTokens) >= 2 && len(dbFiles) >= 1 {
|
|
correlations = append(correlations, CorrelationGroup{
|
|
Pattern: "database_credentials",
|
|
Description: "Database connection credentials found together",
|
|
Tokens: dbTokens,
|
|
Files: dbFiles,
|
|
Confidence: 0.8,
|
|
DetectedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
// Pattern 2: AWS credential pairs (Access Key + Secret)
|
|
awsTokens := make([]string, 0)
|
|
awsFiles := make([]string, 0)
|
|
|
|
for token, files := range t.secretRefs {
|
|
// Look for any APIKEY or SECRET tokens - AWS detection can be broader
|
|
if strings.Contains(token, "APIKEY") || strings.Contains(token, "SECRET") {
|
|
awsTokens = append(awsTokens, token)
|
|
for _, file := range files {
|
|
found := false
|
|
for _, existing := range awsFiles {
|
|
if existing == file {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
awsFiles = append(awsFiles, file)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(awsTokens) >= 2 && len(awsFiles) >= 1 {
|
|
correlations = append(correlations, CorrelationGroup{
|
|
Pattern: "aws_credentials",
|
|
Description: "AWS credential pair (access key + secret) found together",
|
|
Tokens: awsTokens,
|
|
Files: awsFiles,
|
|
Confidence: 0.9,
|
|
DetectedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
// Pattern 3: API authentication (API key + token)
|
|
apiTokens := make([]string, 0)
|
|
apiFiles := make([]string, 0)
|
|
|
|
for token, files := range t.secretRefs {
|
|
if strings.Contains(token, "APIKEY") || strings.Contains(token, "TOKEN") {
|
|
apiTokens = append(apiTokens, token)
|
|
for _, file := range files {
|
|
found := false
|
|
for _, existing := range apiFiles {
|
|
if existing == file {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
apiFiles = append(apiFiles, file)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(apiTokens) >= 2 && len(apiFiles) >= 1 {
|
|
correlations = append(correlations, CorrelationGroup{
|
|
Pattern: "api_authentication",
|
|
Description: "API authentication tokens found together",
|
|
Tokens: apiTokens,
|
|
Files: apiFiles,
|
|
Confidence: 0.7,
|
|
DetectedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
t.correlations = correlations
|
|
}
|
|
|
|
// GetBundleID returns the unique bundle identifier
|
|
func (t *Tokenizer) GetBundleID() string {
|
|
t.mutex.RLock()
|
|
defer t.mutex.RUnlock()
|
|
return t.bundleID
|
|
}
|
|
|
|
// GetDuplicateGroups returns all duplicate secret groups
|
|
func (t *Tokenizer) GetDuplicateGroups() []DuplicateGroup {
|
|
t.mutex.RLock()
|
|
defer t.mutex.RUnlock()
|
|
|
|
duplicates := make([]DuplicateGroup, 0, len(t.duplicateGroups))
|
|
for _, group := range t.duplicateGroups {
|
|
if group != nil && group.Count > 1 {
|
|
duplicates = append(duplicates, *group)
|
|
}
|
|
}
|
|
return duplicates
|
|
}
|
|
|
|
// GetFileStats returns statistics for a specific file
|
|
func (t *Tokenizer) GetFileStats(filePath string) (FileStats, bool) {
|
|
t.mutex.RLock()
|
|
defer t.mutex.RUnlock()
|
|
|
|
if stats, exists := t.fileStats[filePath]; exists && stats != nil {
|
|
return *stats, true
|
|
}
|
|
return FileStats{}, false
|
|
}
|
|
|
|
// GetCacheStats returns cache performance statistics
|
|
func (t *Tokenizer) GetCacheStats() CacheStats {
|
|
t.mutex.RLock()
|
|
defer t.mutex.RUnlock()
|
|
return t.cacheStats
|
|
}
|
|
|
|
// normalizeSecret performs various normalizations on secret values for better correlation
|
|
func (t *Tokenizer) normalizeSecret(value string) string {
|
|
// Track original value for statistics
|
|
originalValue := value
|
|
|
|
// 1. Trim whitespace
|
|
value = strings.TrimSpace(value)
|
|
|
|
// 2. Handle common case variations (but preserve case for actual secrets)
|
|
// Only normalize if it looks like a common pattern, not actual credentials
|
|
if len(value) < 8 { // Short values might be user names, etc.
|
|
// Check if it's all letters (might be username)
|
|
if matched, _ := regexp.MatchString(`^[a-zA-Z]+$`, value); matched {
|
|
value = strings.ToLower(value)
|
|
}
|
|
}
|
|
|
|
// 3. Remove common prefixes/suffixes that don't change secret meaning
|
|
prefixes := []string{"Bearer ", "Basic ", "Token ", "API_KEY=", "PASSWORD=", "SECRET="}
|
|
for _, prefix := range prefixes {
|
|
if strings.HasPrefix(value, prefix) {
|
|
value = strings.TrimPrefix(value, prefix)
|
|
break
|
|
}
|
|
}
|
|
|
|
// 4. Handle quotes (both single and double)
|
|
if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) ||
|
|
(strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
|
|
// 5. Normalize common connection string patterns
|
|
// Example: "user:pass@host" vs "user: pass @ host"
|
|
value = regexp.MustCompile(`\s*:\s*`).ReplaceAllString(value, ":")
|
|
value = regexp.MustCompile(`\s*@\s*`).ReplaceAllString(value, "@")
|
|
|
|
// Track normalization statistics
|
|
if value != originalValue {
|
|
t.cacheStats.Total++
|
|
// This is a bit of a hack to track normalization hits
|
|
t.normalizedSecrets[value] = originalValue
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// generateSecretHash creates a consistent hash for secret deduplication
|
|
func (t *Tokenizer) generateSecretHash(normalizedValue string) string {
|
|
h := hmac.New(sha256.New, []byte("secret-hash-salt"))
|
|
h.Write([]byte(normalizedValue))
|
|
hash := h.Sum(nil)
|
|
return hex.EncodeToString(hash[:16]) // Use first 16 bytes for shorter hash
|
|
}
|
|
|
|
// addSecretReference tracks where a token was used
|
|
func (t *Tokenizer) addSecretReference(token, filePath string) {
|
|
if t.secretRefs == nil {
|
|
t.secretRefs = make(map[string][]string)
|
|
}
|
|
|
|
// Check if file already exists for this token
|
|
for _, existingFile := range t.secretRefs[token] {
|
|
if existingFile == filePath {
|
|
return // Already recorded
|
|
}
|
|
}
|
|
|
|
t.secretRefs[token] = append(t.secretRefs[token], filePath)
|
|
}
|
|
|
|
// trackDuplicateSecret manages duplicate secret detection and tracking
|
|
func (t *Tokenizer) trackDuplicateSecret(secretHash, token, secretType, filePath string, normalizedValue string) {
|
|
now := time.Now()
|
|
|
|
if existing, exists := t.duplicateGroups[secretHash]; exists {
|
|
// Update existing duplicate group
|
|
existing.Count++
|
|
existing.LastSeen = now
|
|
|
|
// Add location if not already present
|
|
for _, loc := range existing.Locations {
|
|
if loc == filePath {
|
|
return // Location already tracked
|
|
}
|
|
}
|
|
existing.Locations = append(existing.Locations, filePath)
|
|
} else {
|
|
// Create new duplicate group
|
|
t.duplicateGroups[secretHash] = &DuplicateGroup{
|
|
SecretHash: secretHash,
|
|
Token: token,
|
|
SecretType: secretType,
|
|
Locations: []string{filePath},
|
|
Count: 1,
|
|
FirstSeen: now,
|
|
LastSeen: now,
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateFileStats tracks statistics per file
|
|
func (t *Tokenizer) updateFileStats(filePath, secretType string) {
|
|
if t.fileStats == nil {
|
|
t.fileStats = make(map[string]*FileStats)
|
|
}
|
|
|
|
stats, exists := t.fileStats[filePath]
|
|
if !exists {
|
|
stats = &FileStats{
|
|
FilePath: filePath,
|
|
SecretsFound: 0,
|
|
TokensUsed: 0,
|
|
SecretTypes: make(map[string]int),
|
|
ProcessedAt: time.Now(),
|
|
}
|
|
t.fileStats[filePath] = stats
|
|
}
|
|
|
|
stats.SecretsFound++
|
|
stats.TokensUsed++
|
|
stats.SecretTypes[secretType]++
|
|
stats.ProcessedAt = time.Now()
|
|
}
|
|
|
|
// Phase 2.2: Redaction Mapping System
|
|
|
|
// GenerateRedactionMapFile creates a redaction mapping file with optional encryption
|
|
func (t *Tokenizer) GenerateRedactionMapFile(profile, outputPath string, encrypt bool) error {
|
|
// Analyze correlations before generating map
|
|
t.analyzeCorrelations()
|
|
|
|
// Get the redaction map
|
|
redactionMap := t.GetRedactionMap(profile)
|
|
|
|
// Encrypt if requested
|
|
if encrypt {
|
|
encryptionKey := make([]byte, 32)
|
|
if _, err := rand.Read(encryptionKey); err != nil {
|
|
return fmt.Errorf("failed to generate encryption key: %w", err)
|
|
}
|
|
|
|
encryptedMap, err := t.encryptRedactionMap(redactionMap, encryptionKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt redaction map: %w", err)
|
|
}
|
|
|
|
redactionMap = encryptedMap
|
|
redactionMap.IsEncrypted = true
|
|
}
|
|
|
|
// Marshal to JSON
|
|
jsonData, err := json.MarshalIndent(redactionMap, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal redaction map: %w", err)
|
|
}
|
|
|
|
// Write to file with secure permissions
|
|
if err := ioutil.WriteFile(outputPath, jsonData, 0600); err != nil {
|
|
return fmt.Errorf("failed to write redaction map file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// encryptRedactionMap encrypts sensitive parts of the redaction map
|
|
func (t *Tokenizer) encryptRedactionMap(redactionMap RedactionMap, encryptionKey []byte) (RedactionMap, error) {
|
|
// Create cipher
|
|
block, err := aes.NewCipher(encryptionKey)
|
|
if err != nil {
|
|
return redactionMap, fmt.Errorf("failed to create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return redactionMap, fmt.Errorf("failed to create GCM: %w", err)
|
|
}
|
|
|
|
// Encrypt the tokens map
|
|
encryptedTokens := make(map[string]string)
|
|
for token, originalValue := range redactionMap.Tokens {
|
|
// Generate nonce
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return redactionMap, fmt.Errorf("failed to generate nonce: %w", err)
|
|
}
|
|
|
|
// Encrypt the original value
|
|
encryptedValue := gcm.Seal(nonce, nonce, []byte(originalValue), nil)
|
|
encryptedTokens[token] = hex.EncodeToString(encryptedValue)
|
|
}
|
|
|
|
// Create encrypted copy
|
|
encryptedMap := redactionMap
|
|
encryptedMap.Tokens = encryptedTokens
|
|
encryptedMap.EncryptionKey = encryptionKey // Store key (won't be serialized due to json:"-")
|
|
encryptedMap.IsEncrypted = true // Mark as encrypted
|
|
|
|
return encryptedMap, nil
|
|
}
|
|
|
|
// decryptRedactionMap decrypts an encrypted redaction map
|
|
func (t *Tokenizer) decryptRedactionMap(encryptedMap RedactionMap, encryptionKey []byte) (RedactionMap, error) {
|
|
if !encryptedMap.IsEncrypted {
|
|
return encryptedMap, nil // Not encrypted
|
|
}
|
|
|
|
// Create cipher
|
|
block, err := aes.NewCipher(encryptionKey)
|
|
if err != nil {
|
|
return encryptedMap, fmt.Errorf("failed to create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return encryptedMap, fmt.Errorf("failed to create GCM: %w", err)
|
|
}
|
|
|
|
// Decrypt the tokens map
|
|
decryptedTokens := make(map[string]string)
|
|
for token, encryptedValue := range encryptedMap.Tokens {
|
|
// Decode hex
|
|
encryptedBytes, err := hex.DecodeString(encryptedValue)
|
|
if err != nil {
|
|
continue // Skip malformed entries
|
|
}
|
|
|
|
if len(encryptedBytes) < gcm.NonceSize() {
|
|
continue // Invalid data
|
|
}
|
|
|
|
// Extract nonce and ciphertext
|
|
nonce := encryptedBytes[:gcm.NonceSize()]
|
|
ciphertext := encryptedBytes[gcm.NonceSize():]
|
|
|
|
// Decrypt
|
|
decryptedBytes, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
continue // Skip failed decryptions
|
|
}
|
|
|
|
decryptedTokens[token] = string(decryptedBytes)
|
|
}
|
|
|
|
// Create decrypted copy
|
|
decryptedMap := encryptedMap
|
|
decryptedMap.Tokens = decryptedTokens
|
|
decryptedMap.IsEncrypted = false
|
|
|
|
return decryptedMap, nil
|
|
}
|
|
|
|
// LoadRedactionMapFile loads and optionally decrypts a redaction mapping file
|
|
func LoadRedactionMapFile(filePath string, encryptionKey []byte) (RedactionMap, error) {
|
|
// Read file
|
|
jsonData, err := ioutil.ReadFile(filePath)
|
|
if err != nil {
|
|
return RedactionMap{}, fmt.Errorf("failed to read redaction map file: %w", err)
|
|
}
|
|
|
|
// Parse JSON
|
|
var redactionMap RedactionMap
|
|
if err := json.Unmarshal(jsonData, &redactionMap); err != nil {
|
|
return RedactionMap{}, fmt.Errorf("failed to parse redaction map: %w", err)
|
|
}
|
|
|
|
// Decrypt if needed and key provided
|
|
if redactionMap.IsEncrypted && len(encryptionKey) > 0 {
|
|
tokenizer := &Tokenizer{} // Temporary instance for decryption
|
|
decryptedMap, err := tokenizer.decryptRedactionMap(redactionMap, encryptionKey)
|
|
if err != nil {
|
|
return RedactionMap{}, fmt.Errorf("failed to decrypt redaction map: %w", err)
|
|
}
|
|
return decryptedMap, nil
|
|
}
|
|
|
|
return redactionMap, nil
|
|
}
|
|
|
|
// ValidateRedactionMapFile validates the structure and integrity of a redaction map file
|
|
func ValidateRedactionMapFile(filePath string) error {
|
|
redactionMap, err := LoadRedactionMapFile(filePath, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Basic validation checks
|
|
if redactionMap.BundleID == "" {
|
|
return fmt.Errorf("invalid redaction map: missing bundle ID")
|
|
}
|
|
|
|
if redactionMap.Stats.TotalSecrets != len(redactionMap.Tokens) {
|
|
return fmt.Errorf("invalid redaction map: stats mismatch (expected %d secrets, found %d)",
|
|
redactionMap.Stats.TotalSecrets, len(redactionMap.Tokens))
|
|
}
|
|
|
|
// Validate token format
|
|
tokenizer := &Tokenizer{}
|
|
for token := range redactionMap.Tokens {
|
|
if !tokenizer.ValidateToken(token) {
|
|
return fmt.Errorf("invalid token format: %s", token)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|