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