Fixes #1876 Problem Files are processed in ~13KB chunks by default (10KB of actual data plus a 3KB peek into the next chunk). To infer the line the engine would take the line from chunk's metadata SourceMetadata.Line and would consider it absolute, that is it's relative to the entire file. The problem is it was never set anywhere. Since every chunk had line equals to 1 in metadata, all secret scan results appeared at line 1 + FragmentLineOffset() which resulted in line being relative to the chunk not the file itself. Solution Track cumulative line numbers in handleNonArchiveContent() by counting newlines as chunks are processed. Each DataOrErr now carries the correct starting line, which flows through to SourceMetadata.Filesystem.Line, giving FragmentFirstLineAndLink() the correct fragStart for the final calculation. Affected Sources Directly fixed: Filesystem - now reports accurate line numbers. I have tested it using the test file provided in the issue wget https://gist.githubusercontent.com/det/1526b4c16d0e07ac023d75c912a68658/raw/c3061c14a811205a65cbdcf0065bd3c11d88bfcb/test.txt Not affected (no Line field in proto): S3, GCS, Jenkins, stdin - use handlers but their metadata protos lack a Line field. I think it makes sense for S3 and GCS to report line numbers so it could be a good future change. Partially affected (own line tracking): Git, GitHub, GitLab - regular text diffs use git-based scanning with built-in line tracking (unaffected). However, binary archives (tar.gz, zip) go through HandleBinary() → handlers.HandleFile() and benefit from this fix. others like BitBucket use their own implementations so not affected
221 lines
5.9 KiB
Go
221 lines
5.9 KiB
Go
package sources
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
|
)
|
|
|
|
const (
|
|
// DefaultChunkSize used by the chunker.
|
|
DefaultChunkSize = 10 * 1024
|
|
// DefaultPeekSize is the size of the peek into the previous chunk.
|
|
DefaultPeekSize = 3 * 1024
|
|
// TotalChunkSize is the total size of a chunk with peek data.
|
|
TotalChunkSize = DefaultChunkSize + DefaultPeekSize
|
|
)
|
|
|
|
type chunkReaderConfig struct {
|
|
chunkSize int
|
|
totalSize int
|
|
peekSize int
|
|
fileSize int
|
|
}
|
|
|
|
// ConfigOption is a function that configures a chunker.
|
|
type ConfigOption func(*chunkReaderConfig)
|
|
|
|
// WithChunkSize sets the chunk size.
|
|
func WithChunkSize(size int) ConfigOption {
|
|
return func(c *chunkReaderConfig) { c.chunkSize = size }
|
|
}
|
|
|
|
// WithPeekSize sets the peek size.
|
|
func WithPeekSize(size int) ConfigOption {
|
|
return func(c *chunkReaderConfig) { c.peekSize = size }
|
|
}
|
|
|
|
// WithFileSize sets the file size.
|
|
// Note: If WithChunkSize is also provided, WithChunkSize takes precedence.
|
|
func WithFileSize(size int) ConfigOption {
|
|
return func(c *chunkReaderConfig) { c.fileSize = size }
|
|
}
|
|
|
|
// ChunkResult is the output unit of a ChunkReader,
|
|
// it contains the data and error of a chunk.
|
|
type ChunkResult struct {
|
|
data []byte
|
|
// contentSize is the size of actual content, excluding peek data
|
|
contentSize int
|
|
err error
|
|
}
|
|
|
|
// Bytes for a ChunkResult.
|
|
func (cr ChunkResult) Bytes() []byte {
|
|
return cr.data
|
|
}
|
|
|
|
// ContentSize returns the size of actual content, excluding peek data.
|
|
// Use this when you need to process only the unique content of each chunk
|
|
// without the overlapping peek portion.
|
|
func (cr ChunkResult) ContentSize() int {
|
|
return cr.contentSize
|
|
}
|
|
|
|
// Error for a ChunkResult.
|
|
func (cr ChunkResult) Error() error {
|
|
return cr.err
|
|
}
|
|
|
|
// NewChunkResult creates a ChunkResult with the given data and content size.
|
|
func NewChunkResult(data []byte, contentSize int) ChunkResult {
|
|
return ChunkResult{data: data, contentSize: contentSize}
|
|
}
|
|
|
|
// NewChunkResultError creates a ChunkResult containing an error.
|
|
func NewChunkResultError(err error) ChunkResult {
|
|
return ChunkResult{err: err}
|
|
}
|
|
|
|
const (
|
|
// Size thresholds.
|
|
xsmallFileSizeThreshold = 4 * 1024 // 4KB
|
|
smallFileSizeThreshold = 10 * 1024 // 10KB
|
|
mediumFileSizeThreshold = 100 * 1024 // 100KB
|
|
largeFileSizeThreshold = 1 * 1024 * 1024 // 1MB
|
|
|
|
// Chunk sizes.
|
|
xsmallFileChunkSize = 1 << 12 // 4KB
|
|
smallFileChunkSize = 1 << 13 // 8KB
|
|
mediumFileChunkSize = 1 << 14 // 16KB
|
|
largeFileChunkSize = 1 << 15 // 32KB
|
|
xlargeFileChunkSize = 1 << 16 // 64KB
|
|
)
|
|
|
|
// ChunkReader reads chunks from a reader and returns a channel of chunks and a channel of errors.
|
|
// The channel of chunks is closed when the reader is closed.
|
|
// This should be used whenever a large amount of data is read from a reader.
|
|
// Ex: reading attachments, archives, etc.
|
|
type ChunkReader func(ctx context.Context, reader io.Reader) <-chan ChunkResult
|
|
|
|
// NewChunkReader returns a ChunkReader with the given options.
|
|
func NewChunkReader(opts ...ConfigOption) ChunkReader {
|
|
config := applyOptions(opts)
|
|
return createReaderFn(config)
|
|
}
|
|
|
|
func applyOptions(opts []ConfigOption) *chunkReaderConfig {
|
|
// Set defaults.
|
|
config := &chunkReaderConfig{
|
|
chunkSize: DefaultChunkSize, // default
|
|
peekSize: DefaultPeekSize, // default
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(config)
|
|
}
|
|
|
|
// Prioritize chunkSize over fileSize if both are provided.
|
|
if config.fileSize != 0 && config.chunkSize == DefaultChunkSize {
|
|
config.chunkSize = calculateOptimalChunkSize(config.fileSize)
|
|
}
|
|
|
|
config.totalSize = config.chunkSize + config.peekSize
|
|
|
|
return config
|
|
}
|
|
|
|
func calculateOptimalChunkSize(fileSize int) int {
|
|
switch {
|
|
case fileSize < xsmallFileSizeThreshold:
|
|
return xsmallFileChunkSize
|
|
case fileSize < smallFileSizeThreshold:
|
|
return smallFileChunkSize
|
|
case fileSize < mediumFileSizeThreshold:
|
|
return mediumFileChunkSize
|
|
case fileSize < largeFileSizeThreshold:
|
|
return largeFileChunkSize
|
|
default:
|
|
return xlargeFileChunkSize
|
|
}
|
|
}
|
|
|
|
func createReaderFn(config *chunkReaderConfig) ChunkReader {
|
|
return func(ctx context.Context, reader io.Reader) <-chan ChunkResult {
|
|
return readInChunks(ctx, reader, config)
|
|
}
|
|
}
|
|
|
|
func readInChunks(ctx context.Context, reader io.Reader, config *chunkReaderConfig) <-chan ChunkResult {
|
|
const channelSize = 64
|
|
chunkReader := bufio.NewReaderSize(reader, config.chunkSize)
|
|
chunkResultChan := make(chan ChunkResult, channelSize)
|
|
|
|
go func() {
|
|
defer close(chunkResultChan)
|
|
|
|
// Defer a panic recovery to handle any panics that occur while reading, which can sometimes unavoidably happen
|
|
// due to third-party library bugs.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
var panicErr error
|
|
if e, ok := r.(error); ok {
|
|
panicErr = e
|
|
} else {
|
|
panicErr = fmt.Errorf("panic occurred: %v", r)
|
|
}
|
|
chunkResultChan <- NewChunkResultError(fmt.Errorf("panic error: %w", panicErr))
|
|
}
|
|
}()
|
|
|
|
for {
|
|
chunkBytes := make([]byte, config.totalSize)
|
|
chunkBytes = chunkBytes[:config.chunkSize]
|
|
n, err := io.ReadFull(chunkReader, chunkBytes)
|
|
|
|
if n > 0 {
|
|
peekData, _ := chunkReader.Peek(config.totalSize - n)
|
|
chunkBytes = append(chunkBytes[:n], peekData...)
|
|
}
|
|
|
|
// If there is an error other than EOF, or if we have read some bytes, send the chunk.
|
|
// io.ReadFull will only return io.EOF when n == 0.
|
|
var chunkRes ChunkResult
|
|
switch {
|
|
case isErrAndNotEOF(err):
|
|
ctx.Logger().Error(err, "error reading chunk")
|
|
chunkRes = NewChunkResultError(err)
|
|
case n > 0:
|
|
chunkRes = NewChunkResult(chunkBytes, n)
|
|
default:
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case chunkResultChan <- chunkRes:
|
|
}
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return chunkResultChan
|
|
}
|
|
|
|
// reportableErr checks whether the error is one we are interested in flagging.
|
|
func isErrAndNotEOF(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|