detached 0ccc01d

fix(security): redact sensitive urls in logs

commit
0ccc01d90549a1a25872b85d961f805d9bb9165d
author
brice · · 2026-06-20 09:51 UTC
committer
brice · · 2026-06-20 09:58 UTC
parent
2be8535
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +176 -30
M internal/application/usecase/manage_favorites.go +9 -9
@@ -59,7 +59,7 @@ type AddFavoriteInput struct {
5959 // Add creates a new favorite.
6060 func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
6161 log := logging.FromContext(ctx)
62- log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")
62+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite")
6363
6464 // Check if already favorited
6565 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
@@ -67,7 +67,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
6767 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
6868 }
6969 if existing != nil {
70- log.Debug().Str("url", input.URL).Msg("URL already favorited")
70+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited")
7171 return existing, nil
7272 }
7373
@@ -95,7 +95,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
9595 }
9696 }
9797
98- log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
98+ log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added")
9999 uc.invalidateCache()
100100 return fav, nil
101101 }
@@ -235,14 +235,14 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
235235 // RemoveByURL deletes a favorite by its URL.
236236 func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
237237 log := logging.FromContext(ctx)
238- log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")
238+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL")
239239
240240 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
241241 if err != nil {
242242 return fmt.Errorf("failed to find favorite: %w", err)
243243 }
244244 if fav == nil {
245- log.Debug().Str("url", favoriteURL).Msg("favorite not found")
245+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found")
246246 return nil
247247 }
248248
@@ -314,7 +314,7 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
314314 // GetByURL finds a favorite by its URL.
315315 func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
316316 log := logging.FromContext(ctx)
317- log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")
317+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL")
318318
319319 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
320320 }
@@ -455,7 +455,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
455455 title = strings.TrimSpace(title)
456456
457457 log := logging.FromContext(ctx)
458- log.Debug().Str("url", favoriteURL).Msg("toggling favorite")
458+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite")
459459
460460 // Check if already favorited, including legacy rows stored before URL canonicalization.
461461 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
@@ -468,7 +468,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
468468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
469469 return nil, fmt.Errorf("failed to remove favorite: %w", err)
470470 }
471- log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
471+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
472472 uc.invalidateCache()
473473 return &ToggleResult{
474474 Added: false,
@@ -483,7 +483,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
483483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
484484 return nil, fmt.Errorf("failed to add favorite: %w", err)
485485 }
486- log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
486+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
487487 uc.invalidateCache()
488488 return &ToggleResult{
489489 Added: true,
@@ -59,7 +59,7 @@ type AddFavoriteInput struct {
59 // Add creates a new favorite.59 // Add creates a new favorite.
60 func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {60 func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
61 log := logging.FromContext(ctx)61 log := logging.FromContext(ctx)
62- log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")62+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite")
63 63
64 // Check if already favorited64 // Check if already favorited
65 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)65 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
@@ -67,7 +67,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
67 return nil, fmt.Errorf("failed to check existing favorite: %w", err)67 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
68 }68 }
69 if existing != nil {69 if existing != nil {
70- log.Debug().Str("url", input.URL).Msg("URL already favorited")70+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited")
71 return existing, nil71 return existing, nil
72 }72 }
73 73
@@ -95,7 +95,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
95 }95 }
96 }96 }
97 97
98- log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")98+ log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added")
99 uc.invalidateCache()99 uc.invalidateCache()
100 return fav, nil100 return fav, nil
101 }101 }
@@ -235,14 +235,14 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
235 // RemoveByURL deletes a favorite by its URL.235 // RemoveByURL deletes a favorite by its URL.
236 func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {236 func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
237 log := logging.FromContext(ctx)237 log := logging.FromContext(ctx)
238- log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")238+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL")
239 239
240 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)240 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
241 if err != nil {241 if err != nil {
242 return fmt.Errorf("failed to find favorite: %w", err)242 return fmt.Errorf("failed to find favorite: %w", err)
243 }243 }
244 if fav == nil {244 if fav == nil {
245- log.Debug().Str("url", favoriteURL).Msg("favorite not found")245+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found")
246 return nil246 return nil
247 }247 }
248 248
@@ -314,7 +314,7 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
314 // GetByURL finds a favorite by its URL.314 // GetByURL finds a favorite by its URL.
315 func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {315 func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
316 log := logging.FromContext(ctx)316 log := logging.FromContext(ctx)
317- log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")317+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL")
318 318
319 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)319 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
320 }320 }
@@ -455,7 +455,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
455 title = strings.TrimSpace(title)455 title = strings.TrimSpace(title)
456 456
457 log := logging.FromContext(ctx)457 log := logging.FromContext(ctx)
458- log.Debug().Str("url", favoriteURL).Msg("toggling favorite")458+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite")
459 459
460 // Check if already favorited, including legacy rows stored before URL canonicalization.460 // Check if already favorited, including legacy rows stored before URL canonicalization.
461 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)461 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
@@ -468,7 +468,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
469 return nil, fmt.Errorf("failed to remove favorite: %w", err)469 return nil, fmt.Errorf("failed to remove favorite: %w", err)
470 }470 }
471- log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")471+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
472 uc.invalidateCache()472 uc.invalidateCache()
473 return &ToggleResult{473 return &ToggleResult{
474 Added: false,474 Added: false,
@@ -483,7 +483,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
484 return nil, fmt.Errorf("failed to add favorite: %w", err)484 return nil, fmt.Errorf("failed to add favorite: %w", err)
485 }485 }
486- log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")486+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
487 uc.invalidateCache()487 uc.invalidateCache()
488 return &ToggleResult{488 return &ToggleResult{
489 Added: true,489 Added: true,
M internal/application/usecase/navigate.go +11 -11
@@ -124,7 +124,7 @@ type NavigateOutput struct {
124124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
125125 log := logging.FromContext(ctx)
126126 log.Debug().
127- Str("url", input.URL).
127+ Str("url", logging.RedactURL(input.URL)).
128128 Str("pane_id", input.PaneID).
129129 Msg("navigating to URL")
130130
@@ -135,7 +135,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
135135 }
136136
137137 log.Info().
138- Str("url", input.URL).
138+ Str("url", logging.RedactURL(input.URL)).
139139 Msg("navigation initiated")
140140
141141 return &NavigateOutput{
@@ -309,7 +309,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
309309 // Check if entry exists
310310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
311311 if err != nil {
312- log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
312+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
313313 return
314314 }
315315
@@ -317,7 +317,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
317317 delta := max(1, record.visits)
318318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
319319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
320- log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")
320+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
321321 return
322322 }
323323 return
@@ -325,7 +325,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
325325 iterations := delta
326326 if iterations > historyFallbackIncrementCap {
327327 log.Warn().
328- Str("url", record.url).
328+ Str("url", logging.RedactURL(record.url)).
329329 Int("delta", delta).
330330 Int("cap", historyFallbackIncrementCap).
331331 Msg("visit count fallback increment capped")
@@ -333,7 +333,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
333333 }
334334 for i := 0; i < iterations; i++ {
335335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
336- log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
336+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
337337 return
338338 }
339339 }
@@ -342,7 +342,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
342342 entry := entity.NewHistoryEntry(record.url, "")
343343 entry.VisitCount = int64(max(1, record.visits))
344344 if err := uc.historyRepo.Save(ctx, entry); err != nil {
345- log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
345+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
346346 }
347347 }
348348 }
@@ -358,7 +358,7 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
358358
359359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
360360 if err != nil {
361- log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")
361+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
362362 return
363363 }
364364 if entry == nil {
@@ -368,12 +368,12 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
368368 entry.Title = title
369369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
370370 if err := updater.UpdateMetadata(ctx, entry); err != nil {
371- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")
371+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
372372 }
373373 return
374374 }
375375 if err := uc.historyRepo.Save(ctx, entry); err != nil {
376- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")
376+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
377377 }
378378 }
379379
@@ -392,7 +392,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit
392392 select {
393393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394394 default:
395- logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")
395+ logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped")
396396 }
397397 }
398398
@@ -124,7 +124,7 @@ type NavigateOutput struct {
124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
125 log := logging.FromContext(ctx)125 log := logging.FromContext(ctx)
126 log.Debug().126 log.Debug().
127- Str("url", input.URL).127+ Str("url", logging.RedactURL(input.URL)).
128 Str("pane_id", input.PaneID).128 Str("pane_id", input.PaneID).
129 Msg("navigating to URL")129 Msg("navigating to URL")
130 130
@@ -135,7 +135,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
135 }135 }
136 136
137 log.Info().137 log.Info().
138- Str("url", input.URL).138+ Str("url", logging.RedactURL(input.URL)).
139 Msg("navigation initiated")139 Msg("navigation initiated")
140 140
141 return &NavigateOutput{141 return &NavigateOutput{
@@ -309,7 +309,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
309 // Check if entry exists309 // Check if entry exists
310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
311 if err != nil {311 if err != nil {
312- log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")312+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
313 return313 return
314 }314 }
315 315
@@ -317,7 +317,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
317 delta := max(1, record.visits)317 delta := max(1, record.visits)
318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
320- log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")320+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
321 return321 return
322 }322 }
323 return323 return
@@ -325,7 +325,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
325 iterations := delta325 iterations := delta
326 if iterations > historyFallbackIncrementCap {326 if iterations > historyFallbackIncrementCap {
327 log.Warn().327 log.Warn().
328- Str("url", record.url).328+ Str("url", logging.RedactURL(record.url)).
329 Int("delta", delta).329 Int("delta", delta).
330 Int("cap", historyFallbackIncrementCap).330 Int("cap", historyFallbackIncrementCap).
331 Msg("visit count fallback increment capped")331 Msg("visit count fallback increment capped")
@@ -333,7 +333,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
333 }333 }
334 for i := 0; i < iterations; i++ {334 for i := 0; i < iterations; i++ {
335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
336- log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")336+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
337 return337 return
338 }338 }
339 }339 }
@@ -342,7 +342,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
342 entry := entity.NewHistoryEntry(record.url, "")342 entry := entity.NewHistoryEntry(record.url, "")
343 entry.VisitCount = int64(max(1, record.visits))343 entry.VisitCount = int64(max(1, record.visits))
344 if err := uc.historyRepo.Save(ctx, entry); err != nil {344 if err := uc.historyRepo.Save(ctx, entry); err != nil {
345- log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")345+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
346 }346 }
347 }347 }
348 }348 }
@@ -358,7 +358,7 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
358 358
359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
360 if err != nil {360 if err != nil {
361- log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")361+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
362 return362 return
363 }363 }
364 if entry == nil {364 if entry == nil {
@@ -368,12 +368,12 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
368 entry.Title = title368 entry.Title = title
369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
370 if err := updater.UpdateMetadata(ctx, entry); err != nil {370 if err := updater.UpdateMetadata(ctx, entry); err != nil {
371- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")371+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
372 }372 }
373 return373 return
374 }374 }
375 if err := uc.historyRepo.Save(ctx, entry); err != nil {375 if err := uc.historyRepo.Save(ctx, entry); err != nil {
376- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")376+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
377 }377 }
378 }378 }
379 379
@@ -392,7 +392,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit
392 select {392 select {
393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394 default:394 default:
395- logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")395+ logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped")
396 }396 }
397 }397 }
398 398
M internal/logging/context.go +2 -2
@@ -51,9 +51,9 @@ func WithTabID(ctx context.Context, tabID string) context.Context {
5151 return WithContext(ctx, childLogger)
5252 }
5353
54-// WithURL creates a child logger with a url field
54+// WithURL creates a child logger with a redacted url field.
5555 func WithURL(ctx context.Context, url string) context.Context {
5656 logger := FromContext(ctx)
57- childLogger := logger.With().Str("url", url).Logger()
57+ childLogger := logger.With().Str("url", RedactURL(url)).Logger()
5858 return WithContext(ctx, childLogger)
5959 }
@@ -51,9 +51,9 @@ func WithTabID(ctx context.Context, tabID string) context.Context {
51 return WithContext(ctx, childLogger)51 return WithContext(ctx, childLogger)
52 }52 }
53 53
54-// WithURL creates a child logger with a url field54+// WithURL creates a child logger with a redacted url field.
55 func WithURL(ctx context.Context, url string) context.Context {55 func WithURL(ctx context.Context, url string) context.Context {
56 logger := FromContext(ctx)56 logger := FromContext(ctx)
57- childLogger := logger.With().Str("url", url).Logger()57+ childLogger := logger.With().Str("url", RedactURL(url)).Logger()
58 return WithContext(ctx, childLogger)58 return WithContext(ctx, childLogger)
59 }59 }
M internal/logging/logger.go +49 -8
@@ -6,6 +6,7 @@ import (
66 neturl "net/url"
77 "os"
88 "path/filepath"
9+ "strings"
910 "sync"
1011 "time"
1112
@@ -71,8 +72,8 @@ func New(cfg Config) zerolog.Logger {
7172 // Returns the logger and a cleanup function to close the file.
7273 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
7374 const (
74- logDirPerm = 0o755
75- logFilePerm = 0o644
75+ logDirPerm = 0o700
76+ logFilePerm = 0o600
7677 )
7778 var writers []io.Writer
7879 cleanup := func() {}
@@ -97,16 +98,24 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
9798
9899 // Session file output (JSON format for parsing)
99100 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
100- // Ensure log directory exists (LogDir is from config, may not have logs subdir)
101+ // Ensure log directory exists (LogDir is from config, may not have logs subdir).
102+ // Session logs may contain browsing metadata, so keep them user-private.
101103 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
102104 return zerolog.Logger{}, nil, err
103105 }
106+ if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil {
107+ return zerolog.Logger{}, nil, err
108+ }
104109
105110 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
106111 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
107112 if err != nil {
108113 return zerolog.Logger{}, nil, err
109114 }
115+ if err := file.Chmod(logFilePerm); err != nil {
116+ _ = file.Close()
117+ return zerolog.Logger{}, nil, err
118+ }
110119
111120 sessionWriter := newSessionFileWriter(file)
112121 writers = append(writers, sessionWriter)
@@ -242,12 +251,44 @@ func ParseLevel(level string) zerolog.Level {
242251 // PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
243252 const PermissionLogURLMaxLen = 96
244253
245-// TruncateURL truncates a URL to maxLen characters for logging.
254+// RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host.
255+const RedactedURLPlaceholder = "[redacted-url]"
256+
257+// RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a
258+// host are reduced to host-only; query strings, fragments, paths, credentials,
259+// and local file paths are omitted.
260+func RedactURL(raw string) string {
261+ raw = strings.TrimSpace(raw)
262+ if raw == "" {
263+ return ""
264+ }
265+
266+ parsed, err := neturl.Parse(raw)
267+ if err != nil {
268+ return RedactedURLPlaceholder
269+ }
270+ if parsed.Host != "" {
271+ return parsed.Hostname()
272+ }
273+ if parsed.Scheme == "about" && parsed.Opaque != "" {
274+ // Use Opaque directly instead of parsed.String() to avoid leaking
275+ // RawQuery and Fragment (Go's URL parser separates them from Opaque,
276+ // but String() reconstructs the full URL including query/fragment).
277+ return parsed.Scheme + ":" + parsed.Opaque
278+ }
279+ if parsed.Scheme != "" {
280+ return parsed.Scheme + ":" + RedactedURLPlaceholder
281+ }
282+ return RedactedURLPlaceholder
283+}
284+
285+// TruncateURL redacts and then truncates a URL for logging.
246286 func TruncateURL(url string, maxLen int) string {
247- if len(url) <= maxLen {
248- return url
287+ redacted := RedactURL(url)
288+ if len(redacted) <= maxLen {
289+ return redacted
249290 }
250- return url[:maxLen] + "..."
291+ return redacted[:maxLen] + "..."
251292 }
252293
253294 // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.
@@ -256,5 +297,5 @@ func SafeURLHost(raw string) string {
256297 if err != nil {
257298 return ""
258299 }
259- return parsed.Host
300+ return parsed.Hostname()
260301 }
@@ -6,6 +6,7 @@ import (
6 neturl "net/url"6 neturl "net/url"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "sync"10 "sync"
10 "time"11 "time"
11 12
@@ -71,8 +72,8 @@ func New(cfg Config) zerolog.Logger {
71 // Returns the logger and a cleanup function to close the file.72 // Returns the logger and a cleanup function to close the file.
72 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {73 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
73 const (74 const (
74- logDirPerm = 0o75575+ logDirPerm = 0o700
75- logFilePerm = 0o64476+ logFilePerm = 0o600
76 )77 )
77 var writers []io.Writer78 var writers []io.Writer
78 cleanup := func() {}79 cleanup := func() {}
@@ -97,16 +98,24 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
97 98
98 // Session file output (JSON format for parsing)99 // Session file output (JSON format for parsing)
99 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {100 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
100- // Ensure log directory exists (LogDir is from config, may not have logs subdir)101+ // Ensure log directory exists (LogDir is from config, may not have logs subdir).
102+ // Session logs may contain browsing metadata, so keep them user-private.
101 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {103 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
102 return zerolog.Logger{}, nil, err104 return zerolog.Logger{}, nil, err
103 }105 }
106+ if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil {
107+ return zerolog.Logger{}, nil, err
108+ }
104 109
105 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))110 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
106 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)111 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
107 if err != nil {112 if err != nil {
108 return zerolog.Logger{}, nil, err113 return zerolog.Logger{}, nil, err
109 }114 }
115+ if err := file.Chmod(logFilePerm); err != nil {
116+ _ = file.Close()
117+ return zerolog.Logger{}, nil, err
118+ }
110 119
111 sessionWriter := newSessionFileWriter(file)120 sessionWriter := newSessionFileWriter(file)
112 writers = append(writers, sessionWriter)121 writers = append(writers, sessionWriter)
@@ -242,12 +251,44 @@ func ParseLevel(level string) zerolog.Level {
242 // PermissionLogURLMaxLen is the standard max length for permission-related URL logging.251 // PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
243 const PermissionLogURLMaxLen = 96252 const PermissionLogURLMaxLen = 96
244 253
245-// TruncateURL truncates a URL to maxLen characters for logging.254+// RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host.
255+const RedactedURLPlaceholder = "[redacted-url]"
256+
257+// RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a
258+// host are reduced to host-only; query strings, fragments, paths, credentials,
259+// and local file paths are omitted.
260+func RedactURL(raw string) string {
261+ raw = strings.TrimSpace(raw)
262+ if raw == "" {
263+ return ""
264+ }
265+
266+ parsed, err := neturl.Parse(raw)
267+ if err != nil {
268+ return RedactedURLPlaceholder
269+ }
270+ if parsed.Host != "" {
271+ return parsed.Hostname()
272+ }
273+ if parsed.Scheme == "about" && parsed.Opaque != "" {
274+ // Use Opaque directly instead of parsed.String() to avoid leaking
275+ // RawQuery and Fragment (Go's URL parser separates them from Opaque,
276+ // but String() reconstructs the full URL including query/fragment).
277+ return parsed.Scheme + ":" + parsed.Opaque
278+ }
279+ if parsed.Scheme != "" {
280+ return parsed.Scheme + ":" + RedactedURLPlaceholder
281+ }
282+ return RedactedURLPlaceholder
283+}
284+
285+// TruncateURL redacts and then truncates a URL for logging.
246 func TruncateURL(url string, maxLen int) string {286 func TruncateURL(url string, maxLen int) string {
247- if len(url) <= maxLen {287+ redacted := RedactURL(url)
248- return url288+ if len(redacted) <= maxLen {
289+ return redacted
249 }290 }
250- return url[:maxLen] + "..."291+ return redacted[:maxLen] + "..."
251 }292 }
252 293
253 // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.294 // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.
@@ -256,5 +297,5 @@ func SafeURLHost(raw string) string {
256 if err != nil {297 if err != nil {
257 return ""298 return ""
258 }299 }
259- return parsed.Host300+ return parsed.Hostname()
260 }301 }
M internal/logging/logger_test.go +105 -0
@@ -3,9 +3,43 @@ package logging
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
8+
9+ "github.com/rs/zerolog"
710 )
811
12+func TestRedactURLUsesHostOnlyForAbsoluteURLs(t *testing.T) {
13+ t.Parallel()
14+
15+ got := RedactURL("https://user:pass@example.com/private/path?token=secret#frag")
16+ if got != "example.com" {
17+ t.Fatalf("RedactURL returned %q, want %q", got, "example.com")
18+ }
19+}
20+
21+func TestRedactURLRedactsLocalOrOpaqueInputs(t *testing.T) {
22+ t.Parallel()
23+
24+ for _, raw := range []string{"/home/user/private/file.html?token=secret", "not a url"} {
25+ if got := RedactURL(raw); got != RedactedURLPlaceholder {
26+ t.Fatalf("RedactURL(%q) returned %q, want %q", raw, got, RedactedURLPlaceholder)
27+ }
28+ }
29+}
30+
31+func TestTruncateURLRedactsBeforeTruncating(t *testing.T) {
32+ t.Parallel()
33+
34+ got := TruncateURL("https://example.com/private/path?token=secret#frag", 96)
35+ if got != "example.com" {
36+ t.Fatalf("TruncateURL returned %q, want %q", got, "example.com")
37+ }
38+ if strings.Contains(got, "token") || strings.Contains(got, "/private") || strings.Contains(got, "frag") {
39+ t.Fatalf("TruncateURL leaked sensitive URL data: %q", got)
40+ }
41+}
42+
943 func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) {
1044 t.Parallel()
1145
@@ -25,6 +59,77 @@ func TestSafeURLHostReturnsEmptyForOpaqueOrInvalidInput(t *testing.T) {
2559 }
2660 }
2761
62+func TestRedactURLStripsQueryFragmentFromAboutOpaqueURLs(t *testing.T) {
63+ t.Parallel()
64+
65+ got := RedactURL("about:blank?token=secret#frag")
66+ if want := "about:blank"; got != want {
67+ t.Fatalf("RedactURL(about:blank?token=secret#frag) = %q, want %q", got, want)
68+ }
69+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
70+ t.Fatalf("RedactURL leaked query/fragment from about: opaque URL: %q", got)
71+ }
72+}
73+
74+func TestRedactURLRedactsFileURLPathQueryFragment(t *testing.T) {
75+ t.Parallel()
76+
77+ got := RedactURL("file:///home/user/private?token=secret#frag")
78+ if got != "file:"+RedactedURLPlaceholder {
79+ t.Fatalf("RedactURL(file:///home/user/private?token=secret#frag) = %q, want %q", got, "file:"+RedactedURLPlaceholder)
80+ }
81+ if strings.Contains(got, "home") || strings.Contains(got, "user") || strings.Contains(got, "private") {
82+ t.Fatalf("RedactURL leaked file path from file:// URL: %q", got)
83+ }
84+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
85+ t.Fatalf("RedactURL leaked query/fragment from file:// URL: %q", got)
86+ }
87+}
88+
89+func TestRedactURLReturnsPlaceholderForInvalidParse(t *testing.T) {
90+ t.Parallel()
91+
92+ got := RedactURL("http://[::1")
93+ if got != RedactedURLPlaceholder {
94+ t.Fatalf("RedactURL(http://[::1) = %q, want %q", got, RedactedURLPlaceholder)
95+ }
96+}
97+
98+func TestNewWithFileCreatesUserPrivateLogDirectoryAndFile(t *testing.T) {
99+ t.Parallel()
100+
101+ parent := t.TempDir()
102+ logDir := filepath.Join(parent, "logs")
103+ logger, cleanup, err := NewWithFile(Config{Level: zerolog.InfoLevel, Format: logFormatJSON}, FileConfig{
104+ Enabled: true,
105+ LogDir: logDir,
106+ SessionID: "test-session",
107+ })
108+ if err != nil {
109+ t.Fatalf("NewWithFile: %v", err)
110+ }
111+ defer cleanup()
112+
113+ logger.Info().Msg("hello")
114+ cleanup()
115+
116+ dirInfo, err := os.Stat(logDir)
117+ if err != nil {
118+ t.Fatalf("stat log dir: %v", err)
119+ }
120+ if got := dirInfo.Mode().Perm(); got != 0o700 {
121+ t.Fatalf("log dir permissions = %o, want 700", got)
122+ }
123+
124+ fileInfo, err := os.Stat(filepath.Join(logDir, SessionFilename("test-session")))
125+ if err != nil {
126+ t.Fatalf("stat log file: %v", err)
127+ }
128+ if got := fileInfo.Mode().Perm(); got != 0o600 {
129+ t.Fatalf("log file permissions = %o, want 600", got)
130+ }
131+}
132+
28133 func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {
29134 t.Parallel()
30135
@@ -3,9 +3,43 @@ package logging
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
8+
9+ "github.com/rs/zerolog"
7 )10 )
8 11
12+func TestRedactURLUsesHostOnlyForAbsoluteURLs(t *testing.T) {
13+ t.Parallel()
14+
15+ got := RedactURL("https://user:pass@example.com/private/path?token=secret#frag")
16+ if got != "example.com" {
17+ t.Fatalf("RedactURL returned %q, want %q", got, "example.com")
18+ }
19+}
20+
21+func TestRedactURLRedactsLocalOrOpaqueInputs(t *testing.T) {
22+ t.Parallel()
23+
24+ for _, raw := range []string{"/home/user/private/file.html?token=secret", "not a url"} {
25+ if got := RedactURL(raw); got != RedactedURLPlaceholder {
26+ t.Fatalf("RedactURL(%q) returned %q, want %q", raw, got, RedactedURLPlaceholder)
27+ }
28+ }
29+}
30+
31+func TestTruncateURLRedactsBeforeTruncating(t *testing.T) {
32+ t.Parallel()
33+
34+ got := TruncateURL("https://example.com/private/path?token=secret#frag", 96)
35+ if got != "example.com" {
36+ t.Fatalf("TruncateURL returned %q, want %q", got, "example.com")
37+ }
38+ if strings.Contains(got, "token") || strings.Contains(got, "/private") || strings.Contains(got, "frag") {
39+ t.Fatalf("TruncateURL leaked sensitive URL data: %q", got)
40+ }
41+}
42+
9 func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) {43 func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) {
10 t.Parallel()44 t.Parallel()
11 45
@@ -25,6 +59,77 @@ func TestSafeURLHostReturnsEmptyForOpaqueOrInvalidInput(t *testing.T) {
25 }59 }
26 }60 }
27 61
62+func TestRedactURLStripsQueryFragmentFromAboutOpaqueURLs(t *testing.T) {
63+ t.Parallel()
64+
65+ got := RedactURL("about:blank?token=secret#frag")
66+ if want := "about:blank"; got != want {
67+ t.Fatalf("RedactURL(about:blank?token=secret#frag) = %q, want %q", got, want)
68+ }
69+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
70+ t.Fatalf("RedactURL leaked query/fragment from about: opaque URL: %q", got)
71+ }
72+}
73+
74+func TestRedactURLRedactsFileURLPathQueryFragment(t *testing.T) {
75+ t.Parallel()
76+
77+ got := RedactURL("file:///home/user/private?token=secret#frag")
78+ if got != "file:"+RedactedURLPlaceholder {
79+ t.Fatalf("RedactURL(file:///home/user/private?token=secret#frag) = %q, want %q", got, "file:"+RedactedURLPlaceholder)
80+ }
81+ if strings.Contains(got, "home") || strings.Contains(got, "user") || strings.Contains(got, "private") {
82+ t.Fatalf("RedactURL leaked file path from file:// URL: %q", got)
83+ }
84+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
85+ t.Fatalf("RedactURL leaked query/fragment from file:// URL: %q", got)
86+ }
87+}
88+
89+func TestRedactURLReturnsPlaceholderForInvalidParse(t *testing.T) {
90+ t.Parallel()
91+
92+ got := RedactURL("http://[::1")
93+ if got != RedactedURLPlaceholder {
94+ t.Fatalf("RedactURL(http://[::1) = %q, want %q", got, RedactedURLPlaceholder)
95+ }
96+}
97+
98+func TestNewWithFileCreatesUserPrivateLogDirectoryAndFile(t *testing.T) {
99+ t.Parallel()
100+
101+ parent := t.TempDir()
102+ logDir := filepath.Join(parent, "logs")
103+ logger, cleanup, err := NewWithFile(Config{Level: zerolog.InfoLevel, Format: logFormatJSON}, FileConfig{
104+ Enabled: true,
105+ LogDir: logDir,
106+ SessionID: "test-session",
107+ })
108+ if err != nil {
109+ t.Fatalf("NewWithFile: %v", err)
110+ }
111+ defer cleanup()
112+
113+ logger.Info().Msg("hello")
114+ cleanup()
115+
116+ dirInfo, err := os.Stat(logDir)
117+ if err != nil {
118+ t.Fatalf("stat log dir: %v", err)
119+ }
120+ if got := dirInfo.Mode().Perm(); got != 0o700 {
121+ t.Fatalf("log dir permissions = %o, want 700", got)
122+ }
123+
124+ fileInfo, err := os.Stat(filepath.Join(logDir, SessionFilename("test-session")))
125+ if err != nil {
126+ t.Fatalf("stat log file: %v", err)
127+ }
128+ if got := fileInfo.Mode().Perm(); got != 0o600 {
129+ t.Fatalf("log file permissions = %o, want 600", got)
130+ }
131+}
132+
28 func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {133 func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {
29 t.Parallel()134 t.Parallel()
30 135

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