408 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			408 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package shhh
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"sort"
 | |
| 	"sync"
 | |
| )
 | |
| 
 | |
| // Option configures the sentinel during construction.
 | |
| type Option func(*Sentinel)
 | |
| 
 | |
| // FindingObserver receives aggregated findings for each redaction operation.
 | |
| type FindingObserver func(context.Context, []Finding)
 | |
| 
 | |
| // WithAuditSink attaches an audit sink for per-redaction events.
 | |
| func WithAuditSink(sink AuditSink) Option {
 | |
| 	return func(s *Sentinel) {
 | |
| 		s.audit = sink
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // WithStats allows callers to supply a shared stats collector.
 | |
| func WithStats(stats *Stats) Option {
 | |
| 	return func(s *Sentinel) {
 | |
| 		s.stats = stats
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // WithFindingObserver registers an observer that is invoked whenever redaction
 | |
| // produces findings.
 | |
| func WithFindingObserver(observer FindingObserver) Option {
 | |
| 	return func(s *Sentinel) {
 | |
| 		if observer == nil {
 | |
| 			return
 | |
| 		}
 | |
| 		s.observers = append(s.observers, observer)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // Sentinel performs secret detection/redaction across text payloads.
 | |
| type Sentinel struct {
 | |
| 	mu          sync.RWMutex
 | |
| 	enabled     bool
 | |
| 	placeholder string
 | |
| 	rules       []*compiledRule
 | |
| 	audit       AuditSink
 | |
| 	stats       *Stats
 | |
| 	observers   []FindingObserver
 | |
| }
 | |
| 
 | |
| // NewSentinel creates a new secrets sentinel using the provided configuration.
 | |
| func NewSentinel(cfg Config, opts ...Option) (*Sentinel, error) {
 | |
| 	placeholder := cfg.RedactionPlaceholder
 | |
| 	if placeholder == "" {
 | |
| 		placeholder = "[REDACTED]"
 | |
| 	}
 | |
| 
 | |
| 	s := &Sentinel{
 | |
| 		enabled:     !cfg.Disabled,
 | |
| 		placeholder: placeholder,
 | |
| 		stats:       NewStats(),
 | |
| 	}
 | |
| 	for _, opt := range opts {
 | |
| 		opt(s)
 | |
| 	}
 | |
| 	if s.stats == nil {
 | |
| 		s.stats = NewStats()
 | |
| 	}
 | |
| 
 | |
| 	rules, err := compileRules(cfg, placeholder)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("compile SHHH rules: %w", err)
 | |
| 	}
 | |
| 	if len(rules) == 0 {
 | |
| 		return nil, errors.New("no SHHH rules configured")
 | |
| 	}
 | |
| 	s.rules = rules
 | |
| 
 | |
| 	return s, nil
 | |
| }
 | |
| 
 | |
| // Enabled reports whether the sentinel is actively redacting.
 | |
| func (s *Sentinel) Enabled() bool {
 | |
| 	s.mu.RLock()
 | |
| 	defer s.mu.RUnlock()
 | |
| 	return s.enabled
 | |
| }
 | |
| 
 | |
| // Toggle enables or disables the sentinel at runtime.
 | |
| func (s *Sentinel) Toggle(enabled bool) {
 | |
| 	s.mu.Lock()
 | |
| 	defer s.mu.Unlock()
 | |
| 	s.enabled = enabled
 | |
| }
 | |
| 
 | |
| // SetAuditSink updates the audit sink at runtime.
 | |
| func (s *Sentinel) SetAuditSink(sink AuditSink) {
 | |
| 	s.mu.Lock()
 | |
| 	defer s.mu.Unlock()
 | |
| 	s.audit = sink
 | |
| }
 | |
| 
 | |
| // AddFindingObserver registers an observer after construction.
 | |
| func (s *Sentinel) AddFindingObserver(observer FindingObserver) {
 | |
| 	if observer == nil {
 | |
| 		return
 | |
| 	}
 | |
| 	s.mu.Lock()
 | |
| 	defer s.mu.Unlock()
 | |
| 	s.observers = append(s.observers, observer)
 | |
| }
 | |
| 
 | |
| // StatsSnapshot returns a snapshot of the current counters.
 | |
| func (s *Sentinel) StatsSnapshot() StatsSnapshot {
 | |
| 	s.mu.RLock()
 | |
| 	stats := s.stats
 | |
| 	s.mu.RUnlock()
 | |
| 	if stats == nil {
 | |
| 		return StatsSnapshot{}
 | |
| 	}
 | |
| 	return stats.Snapshot()
 | |
| }
 | |
| 
 | |
| // RedactText scans the provided text and redacts any findings.
 | |
| func (s *Sentinel) RedactText(ctx context.Context, text string, labels map[string]string) (string, []Finding) {
 | |
| 	s.mu.RLock()
 | |
| 	enabled := s.enabled
 | |
| 	rules := s.rules
 | |
| 	stats := s.stats
 | |
| 	audit := s.audit
 | |
| 	s.mu.RUnlock()
 | |
| 
 | |
| 	if !enabled || len(rules) == 0 {
 | |
| 		return text, nil
 | |
| 	}
 | |
| 	if stats != nil {
 | |
| 		stats.IncScan()
 | |
| 	}
 | |
| 
 | |
| 	aggregates := make(map[string]*findingAggregate)
 | |
| 	current := text
 | |
| 	path := derivePath(labels)
 | |
| 
 | |
| 	for _, rule := range rules {
 | |
| 		redacted, matches := rule.apply(current)
 | |
| 		if len(matches) == 0 {
 | |
| 			continue
 | |
| 		}
 | |
| 		current = redacted
 | |
| 		if stats != nil {
 | |
| 			stats.AddFindings(rule.name, len(matches))
 | |
| 		}
 | |
| 		recordAggregate(aggregates, rule, path, len(matches))
 | |
| 
 | |
| 		if audit != nil {
 | |
| 			metadata := cloneLabels(labels)
 | |
| 			for _, match := range matches {
 | |
| 				event := AuditEvent{
 | |
| 					Rule:     rule.name,
 | |
| 					Severity: rule.severity,
 | |
| 					Tags:     append([]string(nil), rule.tags...),
 | |
| 					Path:     path,
 | |
| 					Hash:     hashSecret(match.value),
 | |
| 					Metadata: metadata,
 | |
| 				}
 | |
| 				audit.RecordRedaction(ctx, event)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	findings := flattenAggregates(aggregates)
 | |
| 	s.notifyObservers(ctx, findings)
 | |
| 	return current, findings
 | |
| }
 | |
| 
 | |
| // RedactMap walks the map and redacts in-place. It returns the collected findings.
 | |
| func (s *Sentinel) RedactMap(ctx context.Context, payload map[string]any) []Finding {
 | |
| 	return s.RedactMapWithLabels(ctx, payload, nil)
 | |
| }
 | |
| 
 | |
| // RedactMapWithLabels allows callers to specify base labels that will be merged
 | |
| // into metadata for nested structures.
 | |
| func (s *Sentinel) RedactMapWithLabels(ctx context.Context, payload map[string]any, baseLabels map[string]string) []Finding {
 | |
| 	if payload == nil {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	aggregates := make(map[string]*findingAggregate)
 | |
| 	s.redactValue(ctx, payload, "", baseLabels, aggregates)
 | |
| 	findings := flattenAggregates(aggregates)
 | |
| 	s.notifyObservers(ctx, findings)
 | |
| 	return findings
 | |
| }
 | |
| 
 | |
| func (s *Sentinel) redactValue(ctx context.Context, value any, path string, baseLabels map[string]string, agg map[string]*findingAggregate) {
 | |
| 	switch v := value.(type) {
 | |
| 	case map[string]interface{}:
 | |
| 		for key, val := range v {
 | |
| 			childPath := joinPath(path, key)
 | |
| 			switch typed := val.(type) {
 | |
| 			case string:
 | |
| 				labels := mergeLabels(baseLabels, childPath)
 | |
| 				redacted, findings := s.RedactText(ctx, typed, labels)
 | |
| 				if redacted != typed {
 | |
| 					v[key] = redacted
 | |
| 				}
 | |
| 				mergeAggregates(agg, findings)
 | |
| 			case fmt.Stringer:
 | |
| 				labels := mergeLabels(baseLabels, childPath)
 | |
| 				text := typed.String()
 | |
| 				redacted, findings := s.RedactText(ctx, text, labels)
 | |
| 				if redacted != text {
 | |
| 					v[key] = redacted
 | |
| 				}
 | |
| 				mergeAggregates(agg, findings)
 | |
| 			default:
 | |
| 				s.redactValue(ctx, typed, childPath, baseLabels, agg)
 | |
| 			}
 | |
| 		}
 | |
| 	case []interface{}:
 | |
| 		for idx, item := range v {
 | |
| 			childPath := indexPath(path, idx)
 | |
| 			switch typed := item.(type) {
 | |
| 			case string:
 | |
| 				labels := mergeLabels(baseLabels, childPath)
 | |
| 				redacted, findings := s.RedactText(ctx, typed, labels)
 | |
| 				if redacted != typed {
 | |
| 					v[idx] = redacted
 | |
| 				}
 | |
| 				mergeAggregates(agg, findings)
 | |
| 			case fmt.Stringer:
 | |
| 				labels := mergeLabels(baseLabels, childPath)
 | |
| 				text := typed.String()
 | |
| 				redacted, findings := s.RedactText(ctx, text, labels)
 | |
| 				if redacted != text {
 | |
| 					v[idx] = redacted
 | |
| 				}
 | |
| 				mergeAggregates(agg, findings)
 | |
| 			default:
 | |
| 				s.redactValue(ctx, typed, childPath, baseLabels, agg)
 | |
| 			}
 | |
| 		}
 | |
| 	case []string:
 | |
| 		for idx, item := range v {
 | |
| 			childPath := indexPath(path, idx)
 | |
| 			labels := mergeLabels(baseLabels, childPath)
 | |
| 			redacted, findings := s.RedactText(ctx, item, labels)
 | |
| 			if redacted != item {
 | |
| 				v[idx] = redacted
 | |
| 			}
 | |
| 			mergeAggregates(agg, findings)
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (s *Sentinel) notifyObservers(ctx context.Context, findings []Finding) {
 | |
| 	if len(findings) == 0 {
 | |
| 		return
 | |
| 	}
 | |
| 	findingsCopy := append([]Finding(nil), findings...)
 | |
| 	s.mu.RLock()
 | |
| 	observers := append([]FindingObserver(nil), s.observers...)
 | |
| 	s.mu.RUnlock()
 | |
| 	for _, observer := range observers {
 | |
| 		observer(ctx, findingsCopy)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func mergeAggregates(dest map[string]*findingAggregate, findings []Finding) {
 | |
| 	for i := range findings {
 | |
| 		f := findings[i]
 | |
| 		agg := dest[f.Rule]
 | |
| 		if agg == nil {
 | |
| 			agg = &findingAggregate{
 | |
| 				rule:      f.Rule,
 | |
| 				severity:  f.Severity,
 | |
| 				tags:      append([]string(nil), f.Tags...),
 | |
| 				locations: make(map[string]int),
 | |
| 			}
 | |
| 			dest[f.Rule] = agg
 | |
| 		}
 | |
| 		agg.count += f.Count
 | |
| 		for _, loc := range f.Locations {
 | |
| 			agg.locations[loc.Path] += loc.Count
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func recordAggregate(dest map[string]*findingAggregate, rule *compiledRule, path string, count int) {
 | |
| 	agg := dest[rule.name]
 | |
| 	if agg == nil {
 | |
| 		agg = &findingAggregate{
 | |
| 			rule:      rule.name,
 | |
| 			severity:  rule.severity,
 | |
| 			tags:      append([]string(nil), rule.tags...),
 | |
| 			locations: make(map[string]int),
 | |
| 		}
 | |
| 		dest[rule.name] = agg
 | |
| 	}
 | |
| 	agg.count += count
 | |
| 	if path != "" {
 | |
| 		agg.locations[path] += count
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func flattenAggregates(agg map[string]*findingAggregate) []Finding {
 | |
| 	if len(agg) == 0 {
 | |
| 		return nil
 | |
| 	}
 | |
| 	keys := make([]string, 0, len(agg))
 | |
| 	for key := range agg {
 | |
| 		keys = append(keys, key)
 | |
| 	}
 | |
| 	sort.Strings(keys)
 | |
| 
 | |
| 	findings := make([]Finding, 0, len(agg))
 | |
| 	for _, key := range keys {
 | |
| 		entry := agg[key]
 | |
| 		locations := make([]Location, 0, len(entry.locations))
 | |
| 		if len(entry.locations) > 0 {
 | |
| 			paths := make([]string, 0, len(entry.locations))
 | |
| 			for path := range entry.locations {
 | |
| 				paths = append(paths, path)
 | |
| 			}
 | |
| 			sort.Strings(paths)
 | |
| 			for _, path := range paths {
 | |
| 				locations = append(locations, Location{Path: path, Count: entry.locations[path]})
 | |
| 			}
 | |
| 		}
 | |
| 		findings = append(findings, Finding{
 | |
| 			Rule:      entry.rule,
 | |
| 			Severity:  entry.severity,
 | |
| 			Tags:      append([]string(nil), entry.tags...),
 | |
| 			Count:     entry.count,
 | |
| 			Locations: locations,
 | |
| 		})
 | |
| 	}
 | |
| 	return findings
 | |
| }
 | |
| 
 | |
| func derivePath(labels map[string]string) string {
 | |
| 	if labels == nil {
 | |
| 		return ""
 | |
| 	}
 | |
| 	if path := labels["path"]; path != "" {
 | |
| 		return path
 | |
| 	}
 | |
| 	if path := labels["source"]; path != "" {
 | |
| 		return path
 | |
| 	}
 | |
| 	if path := labels["field"]; path != "" {
 | |
| 		return path
 | |
| 	}
 | |
| 	return ""
 | |
| }
 | |
| 
 | |
| func cloneLabels(labels map[string]string) map[string]string {
 | |
| 	if len(labels) == 0 {
 | |
| 		return nil
 | |
| 	}
 | |
| 	clone := make(map[string]string, len(labels))
 | |
| 	for k, v := range labels {
 | |
| 		clone[k] = v
 | |
| 	}
 | |
| 	return clone
 | |
| }
 | |
| 
 | |
| func joinPath(prefix, key string) string {
 | |
| 	if prefix == "" {
 | |
| 		return key
 | |
| 	}
 | |
| 	if key == "" {
 | |
| 		return prefix
 | |
| 	}
 | |
| 	return prefix + "." + key
 | |
| }
 | |
| 
 | |
| func indexPath(prefix string, idx int) string {
 | |
| 	if prefix == "" {
 | |
| 		return fmt.Sprintf("[%d]", idx)
 | |
| 	}
 | |
| 	return fmt.Sprintf("%s[%d]", prefix, idx)
 | |
| }
 | |
| 
 | |
| func mergeLabels(base map[string]string, path string) map[string]string {
 | |
| 	if base == nil && path == "" {
 | |
| 		return nil
 | |
| 	}
 | |
| 	labels := cloneLabels(base)
 | |
| 	if labels == nil {
 | |
| 		labels = make(map[string]string, 1)
 | |
| 	}
 | |
| 	if path != "" {
 | |
| 		labels["path"] = path
 | |
| 	}
 | |
| 	return labels
 | |
| }
 | |
| 
 | |
| type findingAggregate struct {
 | |
| 	rule      string
 | |
| 	severity  Severity
 | |
| 	tags      []string
 | |
| 	count     int
 | |
| 	locations map[string]int
 | |
| }
 | 
