v0.50.1 a637010

sanitize.go RawHistoryFolder

1package mcp
2
3import (
4	"net/url"
5	"os"
6	"regexp"
7	"sort"
8	"strings"
9	"unicode/utf8"
10)
11
12// This file holds the diagnostics boundary: everything that originates from a
13// server (stderr, HTTP error bodies, JSON-RPC error messages) or from the HTTP
14// client (url.Error carries the full request URL) is scrubbed here before it
15// reaches an error value, the terminal, or a log.
16
17const (
18	redactedMarker = "[redacted]"
19	// minSecretLen keeps one- and two-character configuration values (flags
20	// such as "1") from turning every diagnostic into noise.
21	minSecretLen = 4
22)
23
24// redactor removes configured secret values from server-provided text.
25// A nil *redactor still strips URLs' credentials and control characters.
26type redactor struct {
27	secrets []string
28	// maxLen is the length of the longest secret, used to size the overlap
29	// when long lines are cut for display.
30	maxLen int
31}
32
33// newRedactor collects the secret-bearing values of one server configuration:
34// env and header values, the bearer token, values passed through from the host
35// environment, and credentials embedded in the URL.
36func newRedactor(cfg ServerConfig) *redactor {
37	set := map[string]bool{}
38	add := func(v string) {
39		v = strings.TrimSpace(v)
40		if len(v) < minSecretLen {
41			return
42		}
43		set[v] = true
44		// The control-stripped form too: text is sanitized before it is
45		// matched, so a secret that itself contains escape bytes must match
46		// in its sanitized shape.
47		if clean := sanitizeText(v, true); len(clean) >= minSecretLen {
48			set[clean] = true
49		}
50		// "Bearer abc123" is also worth redacting as just "abc123".
51		if strings.ContainsAny(v, " \t") {
52			for _, field := range strings.Fields(v) {
53				if len(field) >= 8 {
54					set[field] = true
55				}
56			}
57		}
58	}
59	for _, v := range cfg.Env {
60		add(v)
61	}
62	for _, v := range cfg.Headers {
63		add(v)
64	}
65	for _, name := range cfg.EnvHeaders {
66		if v, ok := os.LookupEnv(name); ok {
67			add(v)
68		}
69	}
70	for _, name := range cfg.EnvVars {
71		if v, ok := os.LookupEnv(name); ok {
72			add(v)
73		}
74	}
75	if cfg.BearerTokenEnvVar != "" {
76		if v, ok := os.LookupEnv(cfg.BearerTokenEnvVar); ok {
77			add(v)
78		}
79	}
80	if cfg.URL != "" {
81		for _, v := range urlSecrets(cfg.URL) {
82			add(v)
83		}
84	}
85	r := &redactor{}
86	for v := range set {
87		r.secrets = append(r.secrets, v)
88		r.maxLen = max(r.maxLen, len(v))
89	}
90	// Longest first so a secret containing another is replaced whole.
91	sort.Slice(r.secrets, func(i, j int) bool {
92		if len(r.secrets[i]) != len(r.secrets[j]) {
93			return len(r.secrets[i]) > len(r.secrets[j])
94		}
95		return r.secrets[i] < r.secrets[j]
96	})
97	return r
98}
99
100// urlSecrets returns the secret-bearing parts of raw: userinfo (user and
101// password) and query values, both as written and percent-decoded. It splits
102// the text itself instead of requiring url.Parse to succeed, because the URLs
103// that matter most here are the malformed ones that fail to parse.
104func urlSecrets(raw string) []string {
105	var out []string
106	add := func(v string) {
107		if v == "" {
108			return
109		}
110		out = append(out, v)
111		if d, err := url.PathUnescape(v); err == nil && d != v {
112			out = append(out, d)
113		}
114		if d, err := url.QueryUnescape(v); err == nil && d != v {
115			out = append(out, d)
116		}
117	}
118	rest := raw
119	if i := strings.Index(rest, "://"); i >= 0 {
120		rest = rest[i+3:]
121	}
122	authority := rest
123	if i := strings.IndexAny(authority, "/?#"); i >= 0 {
124		authority = authority[:i]
125	}
126	if i := strings.LastIndex(authority, "@"); i >= 0 {
127		user, pass, _ := strings.Cut(authority[:i], ":")
128		add(user)
129		add(pass)
130	}
131	if i := strings.Index(rest, "?"); i >= 0 {
132		query := rest[i+1:]
133		if j := strings.Index(query, "#"); j >= 0 {
134			query = query[:j]
135		}
136		for _, pair := range strings.FieldsFunc(query, func(r rune) bool { return r == '&' || r == ';' }) {
137			if _, v, ok := strings.Cut(pair, "="); ok {
138				add(v)
139			}
140		}
141		if values, err := url.ParseQuery(query); err == nil {
142			for _, vs := range values {
143				for _, v := range vs {
144					add(v)
145				}
146			}
147		}
148	}
149	return out
150}
151
152// scrub redacts configured secrets, strips credentials and query strings from
153// URLs, and removes terminal control sequences. Newlines are kept.
154func (r *redactor) scrub(s string) string {
155	if s == "" {
156		return s
157	}
158	// Redact, strip control sequences, then redact again: a secret that has
159	// escape bytes inserted into it only becomes matchable once they are gone.
160	s = r.redact(s)
161	s = sanitizeText(s, true)
162	s = r.redact(s)
163	return scrubURLs(s)
164}
165
166// overlap is how many extra bytes to read past a display limit so a secret
167// straddling the limit is captured whole. It is bounded by maxStderrOverlap.
168func (r *redactor) overlap() int {
169	if r == nil {
170		return 0
171	}
172	return min(r.maxLen, maxStderrOverlap)
173}
174
175// redact replaces configured secret values. A nil redactor changes nothing.
176func (r *redactor) redact(s string) string {
177	if r == nil {
178		return s
179	}
180	for _, secret := range r.secrets {
181		s = strings.ReplaceAll(s, secret, redactedMarker)
182	}
183	return s
184}
185
186// trimPartialSecret removes a trailing fragment that is a proper prefix of a
187// secret. It is for text that was cut at an arbitrary byte: a secret that
188// straddles the cut cannot be matched as a whole, and its prefix must not
189// survive either.
190func (r *redactor) trimPartialSecret(s string) string {
191	if r == nil {
192		return s
193	}
194	drop := 0
195	for _, secret := range r.secrets {
196		for k := min(len(secret)-1, len(s)); k > drop; k-- {
197			if strings.HasSuffix(s, secret[:k]) {
198				drop = k
199				break
200			}
201		}
202	}
203	return s[:len(s)-drop]
204}
205
206// scrubOneLine is scrub with newlines collapsed to spaces.
207func (r *redactor) scrubOneLine(s string) string {
208	return sanitizeText(r.scrub(s), false)
209}
210
211var urlInText = regexp.MustCompile("(?i)\\b[a-z][a-z0-9+.-]*://[^\\s\"'<>`]+")
212
213// scrubURLs rewrites every URL in s to scheme://host/path, dropping userinfo,
214// query, and fragment.
215func scrubURLs(s string) string {
216	if !strings.Contains(s, "://") {
217		return s
218	}
219	return urlInText.ReplaceAllStringFunc(s, stripURL)
220}
221
222func stripURL(raw string) string {
223	schemeEnd := strings.Index(raw, "://")
224	if schemeEnd < 0 {
225		return raw
226	}
227	scheme, rest := raw[:schemeEnd+3], raw[schemeEnd+3:]
228	end := len(rest)
229	if i := strings.IndexAny(rest, "/?#"); i >= 0 {
230		end = i
231	}
232	authority, tail := rest[:end], rest[end:]
233	if i := strings.LastIndex(authority, "@"); i >= 0 {
234		authority = authority[i+1:]
235	}
236	if i := strings.IndexAny(tail, "?#"); i >= 0 {
237		tail = tail[:i]
238	}
239	return scheme + authority + tail
240}
241
242// scrubbedError carries a sanitized message while keeping the original error
243// reachable through errors.Is/As.
244type scrubbedError struct {
245	msg string
246	err error
247}
248
249func (e *scrubbedError) Error() string { return e.msg }
250func (e *scrubbedError) Unwrap() error { return e.err }
251
252// scrubError returns err with a scrubbed message, or nil for nil. Context
253// errors are returned unchanged so callers keep comparing them directly.
254func (r *redactor) scrubError(err error) error {
255	if err == nil {
256		return nil
257	}
258	msg := r.scrub(err.Error())
259	if msg == err.Error() {
260		return err
261	}
262	return &scrubbedError{msg: msg, err: err}
263}
264
265// sanitizeText removes terminal control sequences (CSI, OSC, DCS/APC/PM/SOS
266// strings, other escapes), C0/C1 control characters, and bidirectional
267// overrides. Tabs become spaces. Newlines are kept when keepNewlines is true
268// and become spaces otherwise; carriage returns are dropped.
269func sanitizeText(s string, keepNewlines bool) string {
270	clean := true
271	for i := 0; i < len(s); i++ {
272		c := s[i]
273		if c < 0x20 && c != '\n' || c == 0x7f || c >= 0x80 || (c == '\n' && !keepNewlines) {
274			clean = false
275			break
276		}
277	}
278	if clean {
279		return s
280	}
281	var b strings.Builder
282	b.Grow(len(s))
283	for i := 0; i < len(s); {
284		r, size := utf8.DecodeRuneInString(s[i:])
285		i += size
286		switch {
287		case r == 0x1b:
288			i = skipEscape(s, i)
289		case r == 0x9b:
290			i = skipCSI(s, i)
291		case r == 0x9d || r == 0x90 || r == 0x98 || r == 0x9e || r == 0x9f:
292			i = skipControlString(s, i)
293		case r == '\n':
294			if keepNewlines {
295				b.WriteByte('\n')
296			} else {
297				b.WriteByte(' ')
298			}
299		case r == '\t':
300			b.WriteByte(' ')
301		case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
302			// dropped (includes \r)
303		case (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) || r == 0x200e || r == 0x200f:
304			// bidirectional controls can reorder terminal text
305		default:
306			b.WriteRune(r)
307		}
308	}
309	return b.String()
310}
311
312// skipEscape returns the index after the escape sequence whose ESC byte was
313// just consumed (i points at the byte after ESC).
314func skipEscape(s string, i int) int {
315	if i >= len(s) {
316		return i
317	}
318	switch c := s[i]; {
319	case c == '[':
320		return skipCSI(s, i+1)
321	case c == ']' || c == 'P' || c == 'X' || c == '^' || c == '_':
322		return skipControlString(s, i+1)
323	case c >= 0x20 && c <= 0x2f:
324		for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
325			i++
326		}
327		if i < len(s) {
328			i++ // final byte
329		}
330		return i
331	default:
332		return i + 1
333	}
334}
335
336func skipCSI(s string, i int) int {
337	for i < len(s) && s[i] >= 0x30 && s[i] <= 0x3f {
338		i++
339	}
340	for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
341		i++
342	}
343	if i < len(s) && s[i] >= 0x40 && s[i] <= 0x7e {
344		i++
345	}
346	return i
347}
348
349// skipControlString consumes an OSC/DCS/APC/PM/SOS payload up to and
350// including its terminator (BEL, ESC \, or C1 ST). An ESC that does not start
351// ST cancels the string without being consumed.
352func skipControlString(s string, i int) int {
353	for i < len(s) {
354		switch {
355		case s[i] == 0x07:
356			return i + 1
357		case s[i] == 0x1b:
358			if i+1 < len(s) && s[i+1] == '\\' {
359				return i + 2
360			}
361			return i
362		case s[i] == 0xc2 && i+1 < len(s) && s[i+1] == 0x9c:
363			return i + 2
364		}
365		i++
366	}
367	return i
368}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close