From c319b508b6d5432b54f15afe4f8c511ae20e6b0d Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Thu, 20 Aug 2026 16:34:10 -0700 Subject: [PATCH] logger: record field sensitivity instead of destroying it (logger.sensitivity) annotations are enforced by replacing the value with "". That is right for console and JSON logs, but it also destroys data that a columnar sink could hold under a tag and filter per consumer. Adds SensitiveObjectEncoder / SensitiveArrayEncoder: an encoder that can record a value's sensitivity returns a sub-encoder for a level, or nil to refuse it. protoMarshaller asks via ObjectEncoderFor and redacts only what the sink refuses, so an encoder that does not implement the interface behaves exactly as before -- proto_test.go covers that path unchanged. The assertion is on the encoder rather than a new marshaler interface because zapcore's Field.AddTo calls MarshalLogObject directly for InlineMarshalerType, so an encoder-side hook can never intercept a zap.Inline'd proto. Sensitivity propagates through the encoder, not the marshaller: a tagged encoder derives tagged children, so a sensitive message tags its whole subtree without protoMarshaller carrying any state for it. A SECRET field nested under a PII one still redacts, since the sink refuses SECRET at any depth. UnredactedProto is removed. Against a tagging sink it was identical to Proto, and its only remaining behaviour was exposing PII in the clear to sinks that cannot tag. maxLevel goes with it, as every constructor now leaves it at UNSPECIFIED. Co-Authored-By: Claude Opus 5 --- livekit/logger/options.pb.go | 6 +- logger/proto.go | 102 +++++++------ logger/proto_test.go | 60 +------- logger/sensitive.go | 76 ++++++++++ logger/sensitive_test.go | 257 +++++++++++++++++++++++++++++++++ protobufs/logger/options.proto | 6 +- 6 files changed, 405 insertions(+), 102 deletions(-) create mode 100644 logger/sensitive.go create mode 100644 logger/sensitive_test.go diff --git a/livekit/logger/options.pb.go b/livekit/logger/options.pb.go index 768c4bcdb..b3a08fc0a 100644 --- a/livekit/logger/options.pb.go +++ b/livekit/logger/options.pb.go @@ -31,12 +31,12 @@ const ( // User-identifying or user-controlled data: display names, phone numbers, // metadata, attributes, custom headers, auth usernames, semi-public account // identifiers (AWS role ARNs, Azure account names). - // Redacted by logger.Proto(); exposed by logger.UnredactedProto() for - // operator-facing observability events. + // Recorded under a sensitivity tag by a sink that can tag; redacted by one + // that cannot. Sensitivity_SENSITIVITY_PII Sensitivity = 1 // Credentials and authentication material: passwords, access keys, session // tokens, signing keys, API keys, ICE credentials. - // ALWAYS redacted, including by logger.UnredactedProto(). + // ALWAYS redacted: no sink may record it. Sensitivity_SENSITIVITY_SECRET Sensitivity = 2 ) diff --git a/logger/proto.go b/logger/proto.go index 8dd02c067..716570410 100644 --- a/logger/proto.go +++ b/logger/proto.go @@ -32,38 +32,27 @@ import ( "github.com/livekit/protocol/utils/must" ) -// Proto returns a zapcore.ObjectMarshaler that redacts every field whose -// (logger.sensitivity) is at or above SENSITIVITY_PII. Use in routine -// application and debug logs. +// Proto returns a zapcore.ObjectMarshaler that records each field annotated +// with a (logger.sensitivity) under that sensitivity, and redacts it when the +// sink cannot record that level. SECRET is never recordable. func Proto(val proto.Message) zapcore.ObjectMarshaler { if val == nil { return nil } - return protoMarshaller{m: val.ProtoReflect(), maxLevel: logger.Sensitivity_SENSITIVITY_UNSPECIFIED} -} - -// UnredactedProto returns a zapcore.ObjectMarshaler that exposes PII-tier -// fields while still redacting SECRET-tier fields (credentials, tokens, API -// keys). Use only in observability events where operator context legitimately -// requires user-identifying data. -func UnredactedProto(val proto.Message) zapcore.ObjectMarshaler { - if val == nil { - return nil - } - return protoMarshaller{m: val.ProtoReflect(), maxLevel: logger.Sensitivity_SENSITIVITY_PII} + return protoMarshaller{m: val.ProtoReflect()} } // ProtoWithLimit behaves like Proto, but when the message's wire size exceeds // maxBytes it logs a compact summary instead of the full contents: message -// type, wire size, and top-level scalar fields (redacted per sensitivity, -// long strings truncated), with repeated/map fields reduced to counts and -// nested messages dropped. Use for messages of unbounded size, such as RPC -// payloads. +// type, wire size, and top-level scalar fields (tagged or redacted per +// sensitivity, long strings truncated), with repeated/map fields reduced to +// counts and nested messages dropped. Use for messages of unbounded size, such +// as RPC payloads. func ProtoWithLimit(val proto.Message, maxBytes int) zapcore.ObjectMarshaler { if val == nil { return nil } - return protoMarshaller{m: val.ProtoReflect(), maxLevel: logger.Sensitivity_SENSITIVITY_UNSPECIFIED, maxSize: maxBytes} + return protoMarshaller{m: val.ProtoReflect(), maxSize: maxBytes} } var _ zapcore.ObjectMarshaler = protoMarshaller{} @@ -71,8 +60,7 @@ var _ zapcore.ObjectMarshaler = protoMapMarshaller{} var _ zapcore.ArrayMarshaler = protoListMarshaller{} type protoMarshaller struct { - m protoreflect.Message - maxLevel logger.Sensitivity + m protoreflect.Message // maxSize is the wire size in bytes above which the message is logged as // a summary instead of its full contents. 0 means no limit. maxSize int @@ -102,21 +90,32 @@ func (p protoMarshaller) MarshalLogObject(e zapcore.ObjectEncoder) error { continue } - if fieldSensitivity(f) > p.maxLevel { - e.AddString(k, marshalRedacted(f, v)) - continue + // Never assign to e: sibling fields must not inherit this field's + // level. enc records this field, and everything nested under it, at + // the level the field declares. + enc := e + if level := fieldSensitivity(f); level > SensitivityNone { + se := ObjectEncoderFor(e, level) + if se == nil { + e.AddString(k, marshalRedacted(f, v)) + continue + } + enc = se } + // Sensitivity is not re-checked for map values or list elements: proto + // cannot annotate them independently of the containing field, so the + // check above is complete. if f.IsMap() { if m := v.Map(); m.IsValid() { - e.AddObject(k, protoMapMarshaller{f: f, m: m, maxLevel: p.maxLevel}) + enc.AddObject(k, protoMapMarshaller{f: f, m: m}) } } else if f.IsList() { if m := v.List(); m.IsValid() { - e.AddArray(k, protoListMarshaller{f: f, m: m, maxLevel: p.maxLevel}) + enc.AddArray(k, protoListMarshaller{f: f, m: m}) } } else { - marshalProtoField(k, f, v, e, p.maxLevel) + marshalProtoField(k, f, v, enc) } } return nil @@ -140,18 +139,35 @@ func (p protoMarshaller) marshalSummary(e zapcore.ObjectEncoder, size int) error if proto.HasExtension(f.Options(), logger.E_Name) { k = proto.GetExtension(f.Options(), logger.E_Name).(string) } + // These disclose no field contents, so they precede the sensitivity + // decision and stay untagged. Moving the decision above them would + // replace a count with a redaction. switch { case f.IsMap(): e.AddInt(k+"Count", v.Map().Len()) + continue case f.IsList(): e.AddInt(k+"Count", v.List().Len()) + continue case f.Kind() == protoreflect.MessageKind, f.Kind() == protoreflect.GroupKind: - case fieldSensitivity(f) > p.maxLevel: - e.AddString(k, marshalRedacted(f, v)) - case f.Kind() == protoreflect.StringKind: - e.AddString(k, marshalTruncatedString(v.String())) - default: - marshalProtoField(k, f, v, e, p.maxLevel) + continue + } + + enc := e + if level := fieldSensitivity(f); level > SensitivityNone { + se := ObjectEncoderFor(e, level) + if se == nil { + e.AddString(k, marshalRedacted(f, v)) + continue + } + enc = se + } + + // Summaries stay bounded even for values the sink may record. + if f.Kind() == protoreflect.StringKind { + enc.AddString(k, marshalTruncatedString(v.String())) + } else { + marshalProtoField(k, f, v, enc) } } return nil @@ -165,9 +181,8 @@ func marshalTruncatedString(s string) string { } type protoMapMarshaller struct { - f protoreflect.FieldDescriptor - m protoreflect.Map - maxLevel logger.Sensitivity + f protoreflect.FieldDescriptor + m protoreflect.Map } func (p protoMapMarshaller) MarshalLogObject(e zapcore.ObjectEncoder) error { @@ -183,16 +198,15 @@ func (p protoMapMarshaller) MarshalLogObject(e zapcore.ObjectEncoder) error { case protoreflect.StringKind: k = ki.String() } - marshalProtoField(k, p.f.MapValue(), vi, e, p.maxLevel) + marshalProtoField(k, p.f.MapValue(), vi, e) return true }) return nil } type protoListMarshaller struct { - f protoreflect.FieldDescriptor - m protoreflect.List - maxLevel logger.Sensitivity + f protoreflect.FieldDescriptor + m protoreflect.List } func (p protoListMarshaller) MarshalLogArray(e zapcore.ArrayEncoder) error { @@ -214,13 +228,13 @@ func (p protoListMarshaller) MarshalLogArray(e zapcore.ArrayEncoder) error { case protoreflect.BytesKind: e.AppendString(marshalProtoBytes(v.Bytes())) case protoreflect.MessageKind: - e.AppendObject(protoMarshaller{m: v.Message(), maxLevel: p.maxLevel}) + e.AppendObject(protoMarshaller{m: v.Message()}) } } return nil } -func marshalProtoField(k string, f protoreflect.FieldDescriptor, v protoreflect.Value, e zapcore.ObjectEncoder, maxLevel logger.Sensitivity) { +func marshalProtoField(k string, f protoreflect.FieldDescriptor, v protoreflect.Value, e zapcore.ObjectEncoder) { switch f.Kind() { case protoreflect.BoolKind: e.AddBool(k, v.Bool()) @@ -237,7 +251,7 @@ func marshalProtoField(k string, f protoreflect.FieldDescriptor, v protoreflect. case protoreflect.BytesKind: e.AddString(k, marshalProtoBytes(v.Bytes())) case protoreflect.MessageKind: - e.AddObject(k, protoMarshaller{m: v.Message(), maxLevel: maxLevel}) + e.AddObject(k, protoMarshaller{m: v.Message()}) } } diff --git a/logger/proto_test.go b/logger/proto_test.go index 7fca72881..1c140a09c 100644 --- a/logger/proto_test.go +++ b/logger/proto_test.go @@ -58,30 +58,7 @@ func TestProtoRedactsPIIAndSecret(t *testing.T) { require.Equal(t, "", fields["assumeRoleExternalID"]) } -func TestUnredactedProtoShowsPIIRedactsSecret(t *testing.T) { - msg := &livekit.S3Upload{ - AccessKey: "AKIAEXAMPLE", - Secret: "supersecretvalue", - SessionToken: "tokenvalue", - AssumeRoleArn: "arn:aws:iam::123456789012:role/MyRole", - AssumeRoleExternalId: "external-id-1", - Region: "us-east-1", - } - fields := marshalFields(t, logger.UnredactedProto(msg)) - - require.Equal(t, "us-east-1", fields["region"]) - - // PII is exposed. - require.Equal(t, "arn:aws:iam::123456789012:role/MyRole", fields["assumeRoleArn"]) - - // SECRETs remain redacted. - require.Equal(t, "", fields["accessKey"]) - require.Equal(t, "", fields["secret"]) - require.Equal(t, "", fields["sessionToken"]) - require.Equal(t, "", fields["assumeRoleExternalID"]) -} - -func TestProtoRedactFormatPreservedAtPIITier(t *testing.T) { +func TestProtoRedactFormatUsedForPII(t *testing.T) { // ParticipantInfo.metadata is PII with a size-showing redact_format. msg := &livekit.ParticipantInfo{ Identity: "user-123", @@ -94,10 +71,6 @@ func TestProtoRedactFormatPreservedAtPIITier(t *testing.T) { require.Equal(t, "", got["name"]) require.Contains(t, got["metadata"].(string), "") - - gotUnredacted := marshalFields(t, logger.UnredactedProto(msg)) - require.Equal(t, "Alice", gotUnredacted["name"]) - require.Equal(t, `{"plan":"pro"}`, gotUnredacted["metadata"]) } func TestProtoNestedListPIIRedacted(t *testing.T) { @@ -110,22 +83,14 @@ func TestProtoNestedListPIIRedacted(t *testing.T) { }, } - gotRedacted := marshalFields(t, logger.Proto(msg)) - tracks := gotRedacted["tracks"].([]any) + got := marshalFields(t, logger.Proto(msg)) + tracks := got["tracks"].([]any) require.Len(t, tracks, 2) for _, raw := range tracks { track := raw.(map[string]any) require.NotEmpty(t, track["sid"]) require.Equal(t, "", track["name"]) } - - gotUnredacted := marshalFields(t, logger.UnredactedProto(msg)) - tracksU := gotUnredacted["tracks"].([]any) - names := make([]string, 0, 2) - for _, raw := range tracksU { - names = append(names, raw.(map[string]any)["name"].(string)) - } - require.ElementsMatch(t, []string{"Microphone", "Camera"}, names) } func TestProtoNestedSecretAlwaysRedacted(t *testing.T) { @@ -135,19 +100,11 @@ func TestProtoNestedSecretAlwaysRedacted(t *testing.T) { } wrap := &livekit.JoinResponse{IceServers: servers} - for _, name := range []string{"Proto", "UnredactedProto"} { - var m zapcore.ObjectMarshaler - if name == "Proto" { - m = logger.Proto(wrap) - } else { - m = logger.UnredactedProto(wrap) - } - fields := marshalFields(t, m) - iceServers := fields["iceServers"].([]any) - require.Len(t, iceServers, 1) - entry := iceServers[0].(map[string]any) - require.Equal(t, "", entry["credential"], "credential must be redacted by %s", name) - } + fields := marshalFields(t, logger.Proto(wrap)) + iceServers := fields["iceServers"].([]any) + require.Len(t, iceServers, 1) + entry := iceServers[0].(map[string]any) + require.Equal(t, "", entry["credential"]) } func TestProtoWithLimitUnderLimit(t *testing.T) { @@ -233,7 +190,6 @@ func TestProtoWithLimitNilSafe(t *testing.T) { func TestProtoNilSafe(t *testing.T) { require.Nil(t, logger.Proto(nil)) - require.Nil(t, logger.UnredactedProto(nil)) // Typed-nil pointer is not interface-nil; the returned marshaller is // non-nil but emits no fields. diff --git a/logger/sensitive.go b/logger/sensitive.go new file mode 100644 index 000000000..454e73058 --- /dev/null +++ b/logger/sensitive.go @@ -0,0 +1,76 @@ +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger + +import ( + "go.uber.org/zap/zapcore" + + "github.com/livekit/protocol/livekit/logger" +) + +// Sensitivity aliases the schema enum so implementors of the interfaces below +// need only this package. +type Sensitivity = logger.Sensitivity + +const ( + SensitivityNone = logger.Sensitivity_SENSITIVITY_UNSPECIFIED + SensitivityPII = logger.Sensitivity_SENSITIVITY_PII + SensitivitySecret = logger.Sensitivity_SENSITIVITY_SECRET +) + +// SensitiveObjectEncoder is a zapcore.ObjectEncoder that can record a value's +// sensitivity instead of having the value destroyed. An encoder that cannot +// record sensitivity does not implement it, and marshalers redact instead. +type SensitiveObjectEncoder interface { + zapcore.ObjectEncoder + + // SensitiveObjectEncoder returns an encoder that records every value + // written to it, and to encoders derived from it, at sensitivity s. It + // returns nil if this encoder must not record values at s, in which case + // the caller must redact. + // + // Implementations must fail closed by returning nil for any level they do + // not explicitly recognize. The returned encoder may share buffers with the + // receiver and is only valid until the next write on the receiver. + SensitiveObjectEncoder(s Sensitivity) zapcore.ObjectEncoder +} + +// SensitiveArrayEncoder is the zapcore.ArrayEncoder counterpart to +// SensitiveObjectEncoder. +type SensitiveArrayEncoder interface { + zapcore.ArrayEncoder + + SensitiveArrayEncoder(s Sensitivity) zapcore.ArrayEncoder +} + +// ObjectEncoderFor returns an encoder that records values at sensitivity s, or +// nil if e cannot record them. Hand-written marshalers that carry sensitivity +// annotations should use this rather than asserting directly. +func ObjectEncoderFor(e zapcore.ObjectEncoder, s Sensitivity) zapcore.ObjectEncoder { + se, ok := e.(SensitiveObjectEncoder) + if !ok { + return nil + } + return se.SensitiveObjectEncoder(s) +} + +// ArrayEncoderFor is the ArrayEncoder counterpart to ObjectEncoderFor. +func ArrayEncoderFor(e zapcore.ArrayEncoder, s Sensitivity) zapcore.ArrayEncoder { + se, ok := e.(SensitiveArrayEncoder) + if !ok { + return nil + } + return se.SensitiveArrayEncoder(s) +} diff --git a/logger/sensitive_test.go b/logger/sensitive_test.go new file mode 100644 index 000000000..7c3d9602d --- /dev/null +++ b/logger/sensitive_test.go @@ -0,0 +1,257 @@ +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// recorded is a value the sink accepted, plus the sensitivity it was tagged +// with. A redacted field arrives as an ordinary untagged string. +type recorded struct { + v any + sens logger.Sensitivity +} + +// recorder is a sink that records values under dot-joined key paths, mirroring +// how the columnar sink in backend-common flattens nesting. max is the highest +// sensitivity it will accept; anything above that is refused, so the marshaller +// falls back to redaction. +type recorder struct { + t *testing.T + max logger.Sensitivity + vals map[string]recorded +} + +func newRecorder(t *testing.T, max logger.Sensitivity) (*recorder, *recEnc) { + r := &recorder{t: t, max: max, vals: map[string]recorded{}} + return r, &recEnc{ObjectEncoder: zapcore.NewMapObjectEncoder(), r: r} +} + +func (r *recorder) fields(t *testing.T, m zapcore.ObjectMarshaler, e *recEnc) map[string]recorded { + t.Helper() + require.NoError(t, m.MarshalLogObject(e)) + return r.vals +} + +func (r *recorder) put(path string, v any, s logger.Sensitivity) { + r.vals[path] = recorded{v, s} +} + +func join(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// recEnc embeds a MapObjectEncoder to satisfy the ObjectEncoder methods proto.go +// never calls, and overrides the eight it does. +type recEnc struct { + zapcore.ObjectEncoder + r *recorder + prefix string + sens logger.Sensitivity +} + +var _ logger.SensitiveObjectEncoder = (*recEnc)(nil) + +func (e *recEnc) SensitiveObjectEncoder(s logger.Sensitivity) zapcore.ObjectEncoder { + if s > e.r.max { + return nil + } + if s <= e.sens { + return e + } + return &recEnc{ObjectEncoder: e.ObjectEncoder, r: e.r, prefix: e.prefix, sens: s} +} + +func (e *recEnc) add(key string, v any) { e.r.put(join(e.prefix, key), v, e.sens) } + +func (e *recEnc) AddString(key, v string) { e.add(key, v) } +func (e *recEnc) AddInt(key string, v int) { e.add(key, v) } +func (e *recEnc) AddInt64(key string, v int64) { e.add(key, v) } +func (e *recEnc) AddUint64(key string, v uint64) { e.add(key, v) } +func (e *recEnc) AddFloat64(key string, v float64) { e.add(key, v) } +func (e *recEnc) AddBool(key string, v bool) { e.add(key, v) } + +func (e *recEnc) AddObject(key string, m zapcore.ObjectMarshaler) error { + return m.MarshalLogObject(&recEnc{ + ObjectEncoder: e.ObjectEncoder, + r: e.r, + prefix: join(e.prefix, key), + sens: e.sens, + }) +} + +func (e *recEnc) AddArray(key string, m zapcore.ArrayMarshaler) error { + return m.MarshalLogArray(&recArr{r: e.r, prefix: join(e.prefix, key), sens: e.sens}) +} + +// recArr records array elements under an indexed path. +type recArr struct { + zapcore.ArrayEncoder + r *recorder + prefix string + sens logger.Sensitivity + n int +} + +func (e *recArr) path() string { + p := fmt.Sprintf("%s.%d", e.prefix, e.n) + e.n++ + return p +} + +func (e *recArr) append(v any) { e.r.put(e.path(), v, e.sens) } + +func (e *recArr) AppendString(v string) { e.append(v) } +func (e *recArr) AppendBool(v bool) { e.append(v) } +func (e *recArr) AppendInt64(v int64) { e.append(v) } +func (e *recArr) AppendUint64(v uint64) { e.append(v) } +func (e *recArr) AppendFloat64(v float64) { e.append(v) } + +func (e *recArr) AppendObject(m zapcore.ObjectMarshaler) error { + return m.MarshalLogObject(&recEnc{ + ObjectEncoder: zapcore.NewMapObjectEncoder(), + r: e.r, + prefix: e.path(), + sens: e.sens, + }) +} + +// The remaining ArrayEncoder methods are unreachable from proto.go. Reaching one +// means the marshaller grew a path this double does not model. +func (e *recArr) unsupported(name string) { + e.r.t.Fatalf("recArr.%s: unmodelled encoder method", name) +} + +func (e *recArr) AppendArray(zapcore.ArrayMarshaler) error { e.unsupported("AppendArray"); return nil } +func (e *recArr) AppendReflected(any) error { e.unsupported("AppendReflected"); return nil } +func (e *recArr) AppendByteString([]byte) { e.unsupported("AppendByteString") } +func (e *recArr) AppendComplex128(complex128) { e.unsupported("AppendComplex128") } +func (e *recArr) AppendComplex64(complex64) { e.unsupported("AppendComplex64") } +func (e *recArr) AppendDuration(d time.Duration) { e.unsupported("AppendDuration") } +func (e *recArr) AppendTime(t time.Time) { e.unsupported("AppendTime") } +func (e *recArr) AppendFloat32(float32) { e.unsupported("AppendFloat32") } +func (e *recArr) AppendInt(int) { e.unsupported("AppendInt") } +func (e *recArr) AppendInt32(int32) { e.unsupported("AppendInt32") } +func (e *recArr) AppendInt16(int16) { e.unsupported("AppendInt16") } +func (e *recArr) AppendInt8(int8) { e.unsupported("AppendInt8") } +func (e *recArr) AppendUint(uint) { e.unsupported("AppendUint") } +func (e *recArr) AppendUint32(uint32) { e.unsupported("AppendUint32") } +func (e *recArr) AppendUint16(uint16) { e.unsupported("AppendUint16") } +func (e *recArr) AppendUint8(uint8) { e.unsupported("AppendUint8") } +func (e *recArr) AppendUintptr(uintptr) { e.unsupported("AppendUintptr") } + +func requireRecorded(t *testing.T, got map[string]recorded, path string, v any, s logger.Sensitivity) { + t.Helper() + r, ok := got[path] + require.True(t, ok, "missing %q in %v", path, got) + require.Equal(t, v, r.v, "value of %q", path) + require.Equal(t, s, r.sens, "sensitivity of %q", path) +} + +// A sink that can tag stores the real value rather than the redaction, even for +// a field whose redact_format would otherwise produce a size summary. +func TestProtoStoresPIIWhenEncoderCanTag(t *testing.T) { + r, e := newRecorder(t, logger.SensitivityPII) + got := r.fields(t, logger.Proto(&livekit.ParticipantInfo{ + Sid: "PA_x", Identity: "alice", Name: "Alice", Metadata: `{"a":1}`, + }), e) + + requireRecorded(t, got, "sid", "PA_x", logger.SensitivityNone) + requireRecorded(t, got, "identity", "alice", logger.SensitivityNone) + requireRecorded(t, got, "name", "Alice", logger.SensitivityPII) + requireRecorded(t, got, "metadata", `{"a":1}`, logger.SensitivityPII) +} + +// A sink that cannot tag is unaffected: Proto still redacts. +func TestProtoRedactsWhenEncoderCannotTag(t *testing.T) { + r, e := newRecorder(t, logger.SensitivityNone) + got := r.fields(t, logger.Proto(&livekit.ParticipantInfo{ + Sid: "PA_x", Name: "Alice", + }), e) + + requireRecorded(t, got, "sid", "PA_x", logger.SensitivityNone) + requireRecorded(t, got, "name", "", logger.SensitivityNone) +} + +// SECRET is never stored, whatever the sink offers. +func TestProtoNeverStoresSecret(t *testing.T) { + r, e := newRecorder(t, logger.SensitivityPII) + got := r.fields(t, logger.Proto(&livekit.S3Upload{ + AccessKey: "AK", Secret: "SK", SessionToken: "ST", + AssumeRoleArn: "arn:aws:iam::1:role/r", AssumeRoleExternalId: "EID", + Region: "us-east-1", Bucket: "b", + }), e) + + requireRecorded(t, got, "region", "us-east-1", logger.SensitivityNone) + requireRecorded(t, got, "bucket", "b", logger.SensitivityNone) + requireRecorded(t, got, "assumeRoleArn", "arn:aws:iam::1:role/r", logger.SensitivityPII) + for _, k := range []string{"accessKey", "secret", "sessionToken", "assumeRoleExternalID"} { + requireRecorded(t, got, k, "", logger.SensitivityNone) + } +} + +// A sensitive map has no per-entry annotation, so its entries inherit the +// field's level through the encoder. +func TestSensitivityInheritsThroughNesting(t *testing.T) { + r, e := newRecorder(t, logger.SensitivityPII) + got := r.fields(t, logger.Proto(&livekit.ParticipantInfo{ + Sid: "PA_x", Attributes: map[string]string{"a": "1", "b": "2"}, + }), e) + + requireRecorded(t, got, "sid", "PA_x", logger.SensitivityNone) + requireRecorded(t, got, "attributes.a", "1", logger.SensitivityPII) + requireRecorded(t, got, "attributes.b", "2", logger.SensitivityPII) +} + +// Mixed levels inside a repeated message: the PII field is stored tagged while +// the SECRET sibling is still redacted. +func TestSecretUnderPIIStillRedacted(t *testing.T) { + r, e := newRecorder(t, logger.SensitivityPII) + got := r.fields(t, logger.Proto(&livekit.JoinResponse{ + IceServers: []*livekit.ICEServer{{ + Urls: []string{"turn:x"}, Username: "u", Credential: "c", + }}, + }), e) + + requireRecorded(t, got, "iceServers.0.username", "u", logger.SensitivityPII) + requireRecorded(t, got, "iceServers.0.credential", "", logger.SensitivityNone) +} + +// Summary mode tags the scalars it keeps and leaves the non-disclosing counts +// untagged. +func TestSummaryTagsScalars(t *testing.T) { + r, e := newRecorder(t, logger.SensitivityPII) + got := r.fields(t, logger.ProtoWithLimit(&livekit.ParticipantInfo{ + Sid: "PA_x", Name: "Alice", Attributes: map[string]string{"a": "1"}, + }, 1), e) + + requireRecorded(t, got, "truncatedProto", "livekit.ParticipantInfo", logger.SensitivityNone) + requireRecorded(t, got, "sid", "PA_x", logger.SensitivityNone) + requireRecorded(t, got, "name", "Alice", logger.SensitivityPII) + requireRecorded(t, got, "attributesCount", 1, logger.SensitivityNone) + require.NotContains(t, got, "attributes.a") +} diff --git a/protobufs/logger/options.proto b/protobufs/logger/options.proto index d5b12deb6..3535e59e0 100644 --- a/protobufs/logger/options.proto +++ b/protobufs/logger/options.proto @@ -15,13 +15,13 @@ enum Sensitivity { // User-identifying or user-controlled data: display names, phone numbers, // metadata, attributes, custom headers, auth usernames, semi-public account // identifiers (AWS role ARNs, Azure account names). - // Redacted by logger.Proto(); exposed by logger.UnredactedProto() for - // operator-facing observability events. + // Recorded under a sensitivity tag by a sink that can tag; redacted by one + // that cannot. SENSITIVITY_PII = 1; // Credentials and authentication material: passwords, access keys, session // tokens, signing keys, API keys, ICE credentials. - // ALWAYS redacted, including by logger.UnredactedProto(). + // ALWAYS redacted: no sink may record it. SENSITIVITY_SECRET = 2; }