fluid-core/internal/observation/redact.go

236 lines
7.5 KiB
Go
Raw Normal View History

// Package observation implements the FLUID observation plane: telemetry
// normalization, redaction, cohorts, interaction topology, and interface
// pressure classification.
//
// ArchitectureBlueprint.md section 6.2 sets the boundary this package works
// inside: telemetry should be designed for interface learning without becoming
// an unrestricted behavioural capture layer. Raw payload capture is never the
// default, and semantic learning relies on minimized evidence where it can.
package observation
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/tegwick/fluid-core/internal/contract"
)
// RedactionPolicy governs what may reach the evidence store.
//
// It is deterministic configuration, not a heuristic. A privacy filter that
// guesses is a privacy filter that will one day guess wrong, and the evidence
// store is append-only: anything it accepts cannot be taken back out.
type RedactionPolicy struct {
// Salt keys the pseudonymization of consumer identities. It must be stable
// for the life of the interface: a rotated salt breaks every longitudinal
// comparison, because the same consumer starts looking like a new one.
Salt []byte
// AllowRawPayload permits request and response bodies into telemetry.
// Off by default, and Blueprint 6.2 says it should stay that way.
AllowRawPayload bool
// DropQueryParams removes named query parameters from recorded routes.
DropQueryParams []string
// DropHeaders removes named headers from recorded evidence.
DropHeaders []string
// SensitivePatterns match values that must never be stored, wherever they
// appear. Anything matching is replaced rather than dropped, so the shape
// of the evidence survives while the content does not.
SensitivePatterns []*regexp.Regexp
// RetentionDays bounds how long telemetry is kept. Zero means unbounded,
// which should be a deliberate choice rather than an oversight.
RetentionDays int
// CohortMinimumSize is the smallest population that may be reported
// separately. Below it, a "cohort" identifies individuals.
CohortMinimumSize int
}
// DefaultRedactionPolicy returns a conservative policy.
//
// The defaults assume the interface handles something worth protecting. An
// operator who knows otherwise can loosen them explicitly; an operator who has
// not thought about it gets the safe behaviour.
func DefaultRedactionPolicy(salt []byte) RedactionPolicy {
return RedactionPolicy{
Salt: salt,
AllowRawPayload: false,
DropQueryParams: []string{"token", "api_key", "apikey", "access_token", "signature", "password"},
DropHeaders: []string{"authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"},
SensitivePatterns: []*regexp.Regexp{
// Bearer tokens and basic credentials appearing in free text.
regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._~+/-]+=*`),
// Anything that looks like an email address.
regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`),
// Connection strings with embedded credentials.
regexp.MustCompile(`[a-z][a-z0-9+.-]*://[^\s:@/]+:[^\s@/]+@`),
},
RetentionDays: 90,
CohortMinimumSize: 5,
}
}
// ErrNoSalt reports a policy that would pseudonymize with an empty key.
var ErrNoSalt = errors.New("redaction policy has no salt; consumer identities would be trivially reversible")
// Validate checks a policy is usable.
func (p RedactionPolicy) Validate() error {
if len(p.Salt) < 16 {
return ErrNoSalt
}
return nil
}
// Pseudonymize maps a consumer identity to a stable opaque reference.
//
// HMAC rather than a plain hash: a bare SHA-256 of a short identifier — an
// account id, an email — is reversible by anyone willing to enumerate the
// input space, which for most identifier schemes is cheap.
func (p RedactionPolicy) Pseudonymize(identity string) string {
if identity == "" {
return ""
}
mac := hmac.New(sha256.New, p.Salt)
_, _ = mac.Write([]byte(identity))
// Twelve bytes is ample to keep collisions negligible at interface scale
// while keeping the value short enough to read in a terminal.
return "psu-" + hex.EncodeToString(mac.Sum(nil)[:12])
}
// Scrub removes sensitive substrings from free text.
func (p RedactionPolicy) Scrub(s string) (string, bool) {
redacted := false
for _, pattern := range p.SensitivePatterns {
if pattern.MatchString(s) {
s = pattern.ReplaceAllString(s, "[redacted]")
redacted = true
}
}
return s, redacted
}
// CleanRoute strips sensitive query parameters from a recorded route.
//
// The parameter is kept with an emptied value rather than removed. Which
// parameters a consumer sent is itself interface evidence — it tells you what
// they were trying to do — and deleting the key loses that.
func (p RedactionPolicy) CleanRoute(route string) (string, bool) {
idx := strings.IndexByte(route, '?')
if idx < 0 {
return route, false
}
path, rawQuery := route[:idx], route[idx+1:]
values, err := url.ParseQuery(rawQuery)
if err != nil {
// An unparseable query is dropped entirely: it cannot be inspected, so
// it cannot be shown to be safe.
return path, true
}
drop := map[string]bool{}
for _, k := range p.DropQueryParams {
drop[strings.ToLower(k)] = true
}
redacted := false
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
cleaned := url.Values{}
for _, k := range keys {
if drop[strings.ToLower(k)] {
cleaned.Set(k, "[redacted]")
redacted = true
continue
}
for _, v := range values[k] {
scrubbed, hit := p.Scrub(v)
if hit {
redacted = true
}
cleaned.Add(k, scrubbed)
}
}
if len(cleaned) == 0 {
return path, redacted
}
return path + "?" + cleaned.Encode(), redacted
}
// Apply redacts a telemetry event in place and records what it did.
//
// The applied rules are recorded on the event so that later analysis knows what
// it cannot see. Silent redaction would let an analyst mistake an absence of
// evidence for evidence of absence.
func (p RedactionPolicy) Apply(ev *contract.FluidTelemetry) {
var rules []string
if ev.ConsumerRef != "" && !strings.HasPrefix(ev.ConsumerRef, "psu-") {
ev.ConsumerRef = p.Pseudonymize(ev.ConsumerRef)
rules = append(rules, "pseudonymize-consumer")
}
if ev.Request != nil {
if cleaned, hit := p.CleanRoute(ev.Request.Route); hit {
ev.Request.Route = cleaned
rules = append(rules, "clean-route")
}
}
if ev.Error != nil && ev.Error.Detail != "" {
if scrubbed, hit := p.Scrub(ev.Error.Detail); hit {
ev.Error.Detail = scrubbed
rules = append(rules, "scrub-error-detail")
}
}
if ev.Sequence != nil && ev.Sequence.Pattern != "" {
if scrubbed, hit := p.Scrub(ev.Sequence.Pattern); hit {
ev.Sequence.Pattern = scrubbed
rules = append(rules, "scrub-sequence-pattern")
}
}
sort.Strings(rules)
ev.Redaction = &contract.FluidTelemetryRedaction{
Applied: len(rules) > 0,
Rules: rules,
}
}
// Expired reports whether an event has outlived the retention policy.
func (p RedactionPolicy) Expired(ev contract.FluidTelemetry, now time.Time) bool {
if p.RetentionDays <= 0 {
return false
}
return ev.OccurredAt.Before(now.AddDate(0, 0, -p.RetentionDays))
}
// SuppressSmallCohort reports whether a population is too small to report on
// separately.
//
// Blueprint 6.2 requires cohort minimum sizes because a cohort of one is not a
// cohort; it is a named individual with extra steps.
func (p RedactionPolicy) SuppressSmallCohort(size int) bool {
if p.CohortMinimumSize <= 0 {
return false
}
return size < p.CohortMinimumSize
}