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
|
|
}
|