Skip to content

Commit 45219b7

Browse files
committed
fix(agentsessions): redact normalized display secrets
1 parent f681dd0 commit 45219b7

3 files changed

Lines changed: 160 additions & 9 deletions

File tree

‎internal/agentsessions/redaction_order_test.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,39 @@ func TestCombinedRemovedSeparatorsCannotHideSplitCredentials(t *testing.T) {
247247
}
248248
}
249249
}
250+
251+
func TestDisplayFieldDoesNotLeakCredentialFragmentsAcrossNormalizedSeparators(t *testing.T) {
252+
key := "sk-proj-" + strings.Repeat("B", 87)
253+
separators := []struct {
254+
name string
255+
value string
256+
}{
257+
{name: "ESC", value: "\x1b"},
258+
{name: "NUL", value: "\x00"},
259+
{name: "NEL", value: "\u0085"},
260+
{name: "zero width space", value: "\u200b"},
261+
{name: "right-to-left override", value: "\u202e"},
262+
{name: "carriage return", value: "\r"},
263+
{name: "tab", value: "\t"},
264+
}
265+
266+
for _, separator := range separators {
267+
t.Run(separator.name, func(t *testing.T) {
268+
got := DisplayField("problem " + key[:48] + separator.value + key[48:] + " end")
269+
assertNoCredentialRun(t, got, key, 8)
270+
if !strings.Contains(got, "problem") || !strings.Contains(got, "end") || !strings.Contains(got, "[REDACTED]") {
271+
t.Fatalf("DisplayField did not retain readable context and a redaction marker: %q", got)
272+
}
273+
})
274+
}
275+
}
276+
277+
func assertNoCredentialRun(t *testing.T, got, credential string, runLength int) {
278+
t.Helper()
279+
for start := 0; start+runLength <= len(credential); start++ {
280+
run := credential[start : start+runLength]
281+
if strings.Contains(got, run) {
282+
t.Fatalf("output leaked credential run %q at byte %d: %q", run, start, got)
283+
}
284+
}
285+
}

‎internal/agentsessions/translate.go‎

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,93 @@ func redactAtRemovedBoundaries(value string, boundaries []int) string {
135135
return redaction.RedactString(value, redaction.Options{})
136136
}
137137

138+
type byteSpan struct {
139+
start int
140+
end int
141+
}
142+
143+
// redactDisplaySpaceSplits catches credentials whose bytes were separated by
144+
// layout controls. DisplayField keeps those controls as a space for legibility,
145+
// but an attacker can put one inside a key: the ordinary matcher then redacts a
146+
// recognizable prefix and leaves the suffix on screen. Ask the shared redactor
147+
// whether the adjacent ASCII fragments form a known secret, then replace the
148+
// complete original span (including the display space).
149+
//
150+
// It also remaps the remaining removed-control boundaries. Their byte offsets
151+
// feed redactAtRemovedBoundaries, so leaving offsets from before a replacement
152+
// could redact unrelated text later in the field.
153+
func redactDisplaySpaceSplits(value string, boundaries, spaceBoundaries []int) (string, []int) {
154+
const boundaryHint = "\ue000"
155+
spans := make([]byteSpan, 0, len(spaceBoundaries))
156+
for _, position := range spaceBoundaries {
157+
if position < 0 || position >= len(value) || value[position] != ' ' {
158+
continue
159+
}
160+
left := position
161+
for left > 0 && isTextSecretByte(value[left-1]) {
162+
left--
163+
}
164+
right := position + 1
165+
for right < len(value) && isTextSecretByte(value[right]) {
166+
right++
167+
}
168+
if left == position || right == position+1 {
169+
continue
170+
}
171+
candidate := value[left:position] + value[position+1:right]
172+
probe := redaction.RedactString(boundaryHint+candidate, redaction.Options{})
173+
if !strings.Contains(probe, redaction.RedactedSecret) {
174+
continue
175+
}
176+
spans = append(spans, byteSpan{start: left, end: right})
177+
}
178+
if len(spans) == 0 {
179+
return value, boundaries
180+
}
181+
182+
// Boundaries are discovered left-to-right, so overlapping spans can only
183+
// extend the previous span. Merging first prevents duplicate markers when a
184+
// credential contains more than one layout control.
185+
merged := spans[:0]
186+
for _, span := range spans {
187+
if len(merged) > 0 && span.start <= merged[len(merged)-1].end {
188+
if span.end > merged[len(merged)-1].end {
189+
merged[len(merged)-1].end = span.end
190+
}
191+
continue
192+
}
193+
merged = append(merged, span)
194+
}
195+
196+
for i := len(merged) - 1; i >= 0; i-- {
197+
span := merged[i]
198+
value = value[:span.start] + redaction.RedactedSecret + value[span.end:]
199+
delta := len(redaction.RedactedSecret) - (span.end - span.start)
200+
for j, position := range boundaries {
201+
switch {
202+
case position >= span.start && position <= span.end:
203+
boundaries[j] = -1
204+
case position > span.end:
205+
boundaries[j] += delta
206+
}
207+
}
208+
}
209+
kept := boundaries[:0]
210+
for _, position := range boundaries {
211+
if position >= 0 {
212+
kept = append(kept, position)
213+
}
214+
}
215+
return value, kept
216+
}
217+
218+
func isTextSecretByte(value byte) bool {
219+
return value >= 'a' && value <= 'z' ||
220+
value >= 'A' && value <= 'Z' ||
221+
value >= '0' && value <= '9' ||
222+
value == '_' || value == '-' || value == '.'
223+
}
224+
138225
// Every event a translation produces carries sessions.ImportedEventKey. The
139226
// resume digest keeps only the last 80 eligible events, so the boundary note
140227
// persisted ahead of the transcript aged out of the window on the first resume
@@ -682,13 +769,13 @@ func omittedRecordsEvent(count int) sessions.AppendEventInput {
682769

683770
// DisplayField makes one foreign metadata value safe to draw in a terminal.
684771
//
685-
// TWO SEPARATE HAZARDS, WITH REDACTION ON BOTH SIDES OF NORMALIZATION. The value is a field another product
772+
// TWO SEPARATE HAZARDS. The value is a field another product
686773
// wrote into its own file: it can carry terminal escapes that repaint the rows
687774
// around it, and it can carry something shaped like a credential — a title is
688775
// often the user's first prompt, which is where a pasted key ends up.
689776
//
690-
// A pre-pass catches an intact secret immediately after a control/escape; then
691-
// controls are stripped so a secret split by one cannot evade the post-pass.
777+
// Controls are normalized before redaction so a secret split by one cannot
778+
// evade the shape match.
692779
// Layout goes as well, unlike the transcript
693780
// helper, because a metadata field is drawn as one row and a newline in it moves
694781
// the rest of the line somewhere the caller did not intend.
@@ -705,16 +792,14 @@ func omittedRecordsEvent(count int) sessions.AppendEventInput {
705792
// invisible bytes — C0, DEL, C1, Cf — are still DELETED, because those are the
706793
// ones an escape can hide inside a key.
707794
func DisplayField(value string) string {
708-
// Redact once before normalization as well as after it. The first pass catches
709-
// an intact credential immediately following an escape/control sequence; the
710-
// second catches a credential whose bytes were split by controls and become
711-
// contiguous only after those controls are removed.
712-
value = redaction.RedactString(value, redaction.Options{})
713795
var b strings.Builder
714796
b.Grow(len(value))
715797
boundaries := []int{}
798+
spaceBoundaries := []int{}
716799
for _, r := range value {
717800
if r == '\t' || r == '\n' || r == '\r' {
801+
boundaries = appendBoundary(boundaries, b.Len())
802+
spaceBoundaries = appendBoundary(spaceBoundaries, b.Len())
718803
b.WriteRune(' ')
719804
continue
720805
}
@@ -726,5 +811,6 @@ func DisplayField(value string) string {
726811
}
727812
b.WriteRune(r)
728813
}
729-
return strings.TrimSpace(redactAtRemovedBoundaries(b.String(), boundaries))
814+
normalized, boundaries := redactDisplaySpaceSplits(b.String(), boundaries, spaceBoundaries)
815+
return strings.TrimSpace(redactAtRemovedBoundaries(normalized, boundaries))
730816
}

‎internal/tui/session_picker_tabs_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,35 @@ func TestSessionPickerWarningSanitizesEveryDiscoveryProblem(t *testing.T) {
514514
}
515515
}
516516

517+
func TestSessionPickerWarningDoesNotLeakSplitCredentialFragments(t *testing.T) {
518+
key := "sk-proj-" + strings.Repeat("B", 87)
519+
for _, separator := range []struct {
520+
name string
521+
value string
522+
}{
523+
{name: "ESC", value: "\x1b"},
524+
{name: "NUL", value: "\x00"},
525+
{name: "NEL", value: "\u0085"},
526+
{name: "zero width space", value: "\u200b"},
527+
{name: "right-to-left override", value: "\u202e"},
528+
{name: "carriage return", value: "\r"},
529+
{name: "tab", value: "\t"},
530+
} {
531+
t.Run(separator.name, func(t *testing.T) {
532+
got := sessionPickerWarning(nil, []error{errors.New("claude-code: " + key[:48] + separator.value + key[48:])})
533+
for start := 0; start+8 <= len(key); start++ {
534+
run := key[start : start+8]
535+
if strings.Contains(got, run) {
536+
t.Fatalf("picker warning leaked credential run %q at byte %d: %q", run, start, got)
537+
}
538+
}
539+
if !strings.Contains(got, "claude-code") || !strings.Contains(got, "[REDACTED]") {
540+
t.Fatalf("picker warning did not preserve context and a redaction marker: %q", got)
541+
}
542+
})
543+
}
544+
}
545+
517546
// A FAILED IMPORT MUST NOT HIDE THE WORK IT FAILED TO COPY. Import creates the
518547
// local session and appends its transcript separately, so an append that fails
519548
// leaves a session carrying the import tag and no events. That tag alone used to

0 commit comments

Comments
 (0)