@@ -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.
707794func 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}
0 commit comments