FLUID-WP-0005 T02-T04. Redaction is deterministic configuration rather than a heuristic: the evidence store is append-only, so anything it accepts cannot be taken back out. Consumer identities are pseudonymized with HMAC rather than a bare hash, since a plain digest of a short identifier is reversible by enumeration. Applied rules are recorded on each event, so later analysis knows what it cannot see instead of mistaking an absence of evidence for evidence of absence. Redacted query parameters keep their key: which parameters a consumer sent is itself interface evidence. The topology analyzer requires several independent consumers before calling a shape a pattern. One client looping is a client bug; several converging on the same workaround is interface pressure. It also distinguishes a recovered error from a hard failure, because those two deserve different hypotheses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
235 lines
7.5 KiB
Go
235 lines
7.5 KiB
Go
// 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
|
|
}
|