detached 01565b7

fix(crash): ring-buffer log tail and expanded secret redaction

- Replace full-file read with fixed-size ring buffer in readRedactedLogTail
- Add JSON secret redaction ("token":"...", "password":"...", etc.)
- Add Authorization/auth header redaction (Bearer, basic, etc.)
- Add tests for ring buffer ordering and new redaction patterns
commit
01565b79976327422f27b0982ff0ec0dfb8b949b
author
brice · · 2026-02-08 09:40 UTC
parent
c582f01
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +83 -14
M internal/bootstrap/crash_report.go +26 -8
@@ -23,6 +23,7 @@ const (
2323 crashReportsDirName = "crashes"
2424 logTailLineCount = 120
2525 maxCrashReportsKept = 20
26+ scannerMaxTokenSize = 256 * 1024
2627 )
2728
2829 type unexpectedCloseReport struct {
@@ -214,19 +215,30 @@ func readRedactedLogTail(path string, lines int) []string {
214215 }
215216 defer func() { _ = file.Close() }()
216217
217- all := make([]string, 0, lines)
218+ ring := make([]string, lines)
219+ count := 0
218220 scanner := bufio.NewScanner(file)
219- scanner.Buffer(make([]byte, 0, 256*1024), 256*1024)
221+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
220222 for scanner.Scan() {
221- all = append(all, redactSensitiveContent(scanner.Text()))
223+ ring[count%lines] = redactSensitiveContent(scanner.Text())
224+ count++
222225 }
223- if scanner.Err() != nil {
224- all = append(all, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
226+
227+ var result []string
228+ if count <= lines {
229+ result = make([]string, count)
230+ copy(result, ring[:count])
231+ } else {
232+ result = make([]string, lines)
233+ start := count % lines
234+ copy(result, ring[start:])
235+ copy(result[lines-start:], ring[:start])
225236 }
226- if len(all) <= lines {
227- return all
237+
238+ if scanner.Err() != nil {
239+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
228240 }
229- return all[len(all)-lines:]
241+ return result
230242 }
231243
232244 func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
@@ -294,9 +306,15 @@ var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
294306
295307 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
296308
309+var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
310+
311+var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
312+
297313 func redactSensitiveContent(line string) string {
298314 redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
299315 redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
316+ redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
317+ redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
300318 return redacted
301319 }
302320
@@ -23,6 +23,7 @@ const (
23 crashReportsDirName = "crashes"23 crashReportsDirName = "crashes"
24 logTailLineCount = 12024 logTailLineCount = 120
25 maxCrashReportsKept = 2025 maxCrashReportsKept = 20
26+ scannerMaxTokenSize = 256 * 1024
26 )27 )
27 28
28 type unexpectedCloseReport struct {29 type unexpectedCloseReport struct {
@@ -214,19 +215,30 @@ func readRedactedLogTail(path string, lines int) []string {
214 }215 }
215 defer func() { _ = file.Close() }()216 defer func() { _ = file.Close() }()
216 217
217- all := make([]string, 0, lines)218+ ring := make([]string, lines)
219+ count := 0
218 scanner := bufio.NewScanner(file)220 scanner := bufio.NewScanner(file)
219- scanner.Buffer(make([]byte, 0, 256*1024), 256*1024)221+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
220 for scanner.Scan() {222 for scanner.Scan() {
221- all = append(all, redactSensitiveContent(scanner.Text()))223+ ring[count%lines] = redactSensitiveContent(scanner.Text())
224+ count++
222 }225 }
223- if scanner.Err() != nil {226+
224- all = append(all, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))227+ var result []string
228+ if count <= lines {
229+ result = make([]string, count)
230+ copy(result, ring[:count])
231+ } else {
232+ result = make([]string, lines)
233+ start := count % lines
234+ copy(result, ring[start:])
235+ copy(result[lines-start:], ring[:start])
225 }236 }
226- if len(all) <= lines {237+
227- return all238+ if scanner.Err() != nil {
239+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
228 }240 }
229- return all[len(all)-lines:]241+ return result
230 }242 }
231 243
232 func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {244 func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
@@ -294,9 +306,15 @@ var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
294 306
295 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)307 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
296 308
309+var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
310+
311+var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
312+
297 func redactSensitiveContent(line string) string {313 func redactSensitiveContent(line string) string {
298 redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)314 redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
299 redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")315 redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
316+ redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
317+ redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
300 return redacted318 return redacted
301 }319 }
302 320
M internal/bootstrap/crash_report_test.go +57 -6
@@ -2,6 +2,7 @@ package bootstrap
22
33 import (
44 "encoding/json"
5+ "fmt"
56 "os"
67 "path/filepath"
78 "strings"
@@ -14,13 +15,63 @@ import (
1415 )
1516
1617 func TestRedactSensitiveContent(t *testing.T) {
17- line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
18- redacted := redactSensitiveContent(line)
18+ t.Run("url_query_params", func(t *testing.T) {
19+ line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
20+ redacted := redactSensitiveContent(line)
21+ assert.Contains(t, redacted, "https://example.com/cb")
22+ assert.NotContains(t, redacted, "code=abc123")
23+ assert.NotContains(t, redacted, "token=secret")
24+ assert.NotContains(t, redacted, "#frag")
25+ })
1926
20- assert.Contains(t, redacted, "https://example.com/cb")
21- assert.NotContains(t, redacted, "code=abc123")
22- assert.NotContains(t, redacted, "token=secret")
23- assert.NotContains(t, redacted, "#frag")
27+ t.Run("json_secret_fields", func(t *testing.T) {
28+ line := `{"token":"eyJhbGciOi","access_token":"sk-abc123","password":"hunter2"}`
29+ redacted := redactSensitiveContent(line)
30+ assert.NotContains(t, redacted, "eyJhbGciOi")
31+ assert.NotContains(t, redacted, "sk-abc123")
32+ assert.NotContains(t, redacted, "hunter2")
33+ assert.Contains(t, redacted, `"token":"[REDACTED]"`)
34+ assert.Contains(t, redacted, `"access_token":"[REDACTED]"`)
35+ assert.Contains(t, redacted, `"password":"[REDACTED]"`)
36+ })
37+
38+ t.Run("authorization_headers", func(t *testing.T) {
39+ line := "Authorization: Bearer eyJhbGciOiJSUz"
40+ redacted := redactSensitiveContent(line)
41+ assert.NotContains(t, redacted, "eyJhbGciOiJSUz")
42+ assert.Contains(t, redacted, "[REDACTED]")
43+
44+ line2 := "auth: basic dXNlcjpwYXNz"
45+ redacted2 := redactSensitiveContent(line2)
46+ assert.NotContains(t, redacted2, "dXNlcjpwYXNz")
47+ assert.Contains(t, redacted2, "[REDACTED]")
48+ })
49+}
50+
51+func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
52+ dir := t.TempDir()
53+ path := filepath.Join(dir, "test.log")
54+
55+ var lines []string
56+ for i := 0; i < 10; i++ {
57+ lines = append(lines, fmt.Sprintf("line %d", i))
58+ }
59+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))
60+
61+ t.Run("fewer_lines_than_requested", func(t *testing.T) {
62+ result := readRedactedLogTail(path, 20)
63+ assert.Len(t, result, 10)
64+ assert.Equal(t, "line 0", result[0])
65+ assert.Equal(t, "line 9", result[9])
66+ })
67+
68+ t.Run("exact_tail", func(t *testing.T) {
69+ result := readRedactedLogTail(path, 3)
70+ assert.Len(t, result, 3)
71+ assert.Equal(t, "line 7", result[0])
72+ assert.Equal(t, "line 8", result[1])
73+ assert.Equal(t, "line 9", result[2])
74+ })
2475 }
2576
2677 func TestWriteUnexpectedCloseReport(t *testing.T) {
@@ -2,6 +2,7 @@ package bootstrap
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5+ "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "strings"8 "strings"
@@ -14,13 +15,63 @@ import (
14 )15 )
15 16
16 func TestRedactSensitiveContent(t *testing.T) {17 func TestRedactSensitiveContent(t *testing.T) {
17- line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"18+ t.Run("url_query_params", func(t *testing.T) {
18- redacted := redactSensitiveContent(line)19+ line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
20+ redacted := redactSensitiveContent(line)
21+ assert.Contains(t, redacted, "https://example.com/cb")
22+ assert.NotContains(t, redacted, "code=abc123")
23+ assert.NotContains(t, redacted, "token=secret")
24+ assert.NotContains(t, redacted, "#frag")
25+ })
19 26
20- assert.Contains(t, redacted, "https://example.com/cb")27+ t.Run("json_secret_fields", func(t *testing.T) {
21- assert.NotContains(t, redacted, "code=abc123")28+ line := `{"token":"eyJhbGciOi","access_token":"sk-abc123","password":"hunter2"}`
22- assert.NotContains(t, redacted, "token=secret")29+ redacted := redactSensitiveContent(line)
23- assert.NotContains(t, redacted, "#frag")30+ assert.NotContains(t, redacted, "eyJhbGciOi")
31+ assert.NotContains(t, redacted, "sk-abc123")
32+ assert.NotContains(t, redacted, "hunter2")
33+ assert.Contains(t, redacted, `"token":"[REDACTED]"`)
34+ assert.Contains(t, redacted, `"access_token":"[REDACTED]"`)
35+ assert.Contains(t, redacted, `"password":"[REDACTED]"`)
36+ })
37+
38+ t.Run("authorization_headers", func(t *testing.T) {
39+ line := "Authorization: Bearer eyJhbGciOiJSUz"
40+ redacted := redactSensitiveContent(line)
41+ assert.NotContains(t, redacted, "eyJhbGciOiJSUz")
42+ assert.Contains(t, redacted, "[REDACTED]")
43+
44+ line2 := "auth: basic dXNlcjpwYXNz"
45+ redacted2 := redactSensitiveContent(line2)
46+ assert.NotContains(t, redacted2, "dXNlcjpwYXNz")
47+ assert.Contains(t, redacted2, "[REDACTED]")
48+ })
49+}
50+
51+func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
52+ dir := t.TempDir()
53+ path := filepath.Join(dir, "test.log")
54+
55+ var lines []string
56+ for i := 0; i < 10; i++ {
57+ lines = append(lines, fmt.Sprintf("line %d", i))
58+ }
59+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))
60+
61+ t.Run("fewer_lines_than_requested", func(t *testing.T) {
62+ result := readRedactedLogTail(path, 20)
63+ assert.Len(t, result, 10)
64+ assert.Equal(t, "line 0", result[0])
65+ assert.Equal(t, "line 9", result[9])
66+ })
67+
68+ t.Run("exact_tail", func(t *testing.T) {
69+ result := readRedactedLogTail(path, 3)
70+ assert.Len(t, result, 3)
71+ assert.Equal(t, "line 7", result[0])
72+ assert.Equal(t, "line 8", result[1])
73+ assert.Equal(t, "line 9", result[2])
74+ })
24 }75 }
25 76
26 func TestWriteUnexpectedCloseReport(t *testing.T) {77 func TestWriteUnexpectedCloseReport(t *testing.T) {

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 8

Tags 42

↑↓ moveenter openesc close