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 }