Harden CHORUS security and messaging stack
This commit is contained in:
407
pkg/shhh/sentinel.go
Normal file
407
pkg/shhh/sentinel.go
Normal file
@@ -0,0 +1,407 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user