Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/logs/sanitize.go

Raw
package logs

import (
	"bytes"
	"io"
	"sort"
	"sync"
)

const masked = "[MASKED]"

// MaxLogBytes bounds live log output, including the truncation marker.
const MaxLogBytes = 16 << 20

const truncationMarker = "\n[luci] log output truncated\n"

type Sanitizer struct {
	limiter  *limitWriter
	mu       sync.Mutex
	writer   io.Writer
	secrets  [][]byte
	maxLen   int
	pending  []byte
	ansiMode byte
	closed   bool
}

func NewSanitizer(writer io.Writer, secrets [][]byte) *Sanitizer {
	return newSanitizer(writer, secrets, nil)
}

// NewBoundedSanitizer strips ANSI sequences and masks secrets while retaining
// at most MaxLogBytes.  Further output is discarded after one visible marker.
func NewBoundedSanitizer(writer io.Writer, secrets [][]byte) *Sanitizer {
	limiter := &limitWriter{writer: writer, limit: MaxLogBytes}
	return newSanitizer(limiter, secrets, limiter)
}

func newSanitizer(writer io.Writer, secrets [][]byte, limiter *limitWriter) *Sanitizer {
	unique := map[string]struct{}{}
	for _, secret := range secrets {
		if len(secret) > 0 {
			unique[string(secret)] = struct{}{}
		}
	}
	values := make([][]byte, 0, len(unique))
	for secret := range unique {
		values = append(values, []byte(secret))
	}
	sort.Slice(values, func(i, j int) bool { return len(values[i]) > len(values[j]) })
	maxLen := 1
	if len(values) > 0 {
		maxLen = len(values[0])
	}
	return &Sanitizer{writer: writer, secrets: values, maxLen: maxLen, limiter: limiter}
}

// Truncated reports whether NewBoundedSanitizer discarded output.
func (s *Sanitizer) Truncated() bool {
	return s.limiter != nil && s.limiter.Truncated()
}

func (s *Sanitizer) Write(data []byte) (int, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.closed {
		return 0, io.ErrClosedPipe
	}
	for _, value := range data {
		s.consumeANSI(value)
	}
	if err := s.flush(false); err != nil {
		return 0, err
	}
	return len(data), nil
}

func (s *Sanitizer) Close() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.closed {
		return nil
	}
	s.closed = true
	return s.flush(true)
}

func (s *Sanitizer) consumeANSI(value byte) {
	switch s.ansiMode {
	case 0:
		if value == 0x1b {
			s.ansiMode = 1
			return
		}
		s.pending = append(s.pending, value)
	case 1:
		switch value {
		case '[':
			s.ansiMode = 2
		case ']', 'P', 'X', '^', '_':
			s.ansiMode = 3
		default:
			if value >= 0x20 && value <= 0x2f {
				s.ansiMode = 4
			} else {
				s.ansiMode = 0
			}
		}
	case 2:
		if value >= 0x40 && value <= 0x7e {
			s.ansiMode = 0
		}
	case 3:
		if value == 0x07 {
			s.ansiMode = 0
		} else if value == 0x1b {
			s.ansiMode = 5
		}
	case 4:
		if value >= 0x30 && value <= 0x7e {
			s.ansiMode = 0
		}
	case 5:
		if value == '\\' {
			s.ansiMode = 0
		} else if value != 0x1b {
			s.ansiMode = 3
		}
	}
}

func (s *Sanitizer) flush(all bool) error {
	limit := len(s.pending)
	if !all {
		limit -= s.maxLen - 1
		if limit < 0 {
			limit = 0
		}
	}
	position, plainStart := 0, 0
	for position < limit {
		match := s.secretAt(position)
		if match == 0 {
			position++
			continue
		}
		if plainStart < position {
			if _, err := s.writer.Write(s.pending[plainStart:position]); err != nil {
				return err
			}
		}
		if _, err := io.WriteString(s.writer, masked); err != nil {
			return err
		}
		position += match
		plainStart = position
	}
	if plainStart < position {
		if _, err := s.writer.Write(s.pending[plainStart:position]); err != nil {
			return err
		}
	}
	s.pending = append(s.pending[:0], s.pending[position:]...)
	return nil
}

func (s *Sanitizer) secretAt(position int) int {
	for _, secret := range s.secrets {
		if bytes.HasPrefix(s.pending[position:], secret) {
			return len(secret)
		}
	}
	return 0
}

type limitWriter struct {
	mu        sync.Mutex
	writer    io.Writer
	limit     int
	written   int
	truncated bool
}

func (w *limitWriter) Write(data []byte) (int, error) {
	w.mu.Lock()
	defer w.mu.Unlock()
	if w.truncated {
		return len(data), nil
	}
	available := w.limit - len(truncationMarker) - w.written
	if available >= len(data) {
		n, err := w.writer.Write(data)
		w.written += n
		return n, err
	}
	if available > 0 {
		n, err := w.writer.Write(data[:available])
		w.written += n
		if err != nil {
			return n, err
		}
		if n != available {
			return n, io.ErrShortWrite
		}
	}
	n, err := io.WriteString(w.writer, truncationMarker)
	w.written += n
	if err != nil {
		return 0, err
	}
	if n != len(truncationMarker) {
		return 0, io.ErrShortWrite
	}
	w.truncated = true
	return len(data), nil
}

func (w *limitWriter) Truncated() bool {
	w.mu.Lock()
	defer w.mu.Unlock()
	return w.truncated
}