Files
trufflehog/pkg/handlers/handlers.go
John Elliott cf31c26af3 Log non-critical chunk errors at V(2).Info instead of Error (#4928)
Non-fatal scanning errors aren't really "errors" - they're consequences of the vicissitudes of scanning a ton of data. They're actionable if you're looking for them but if you're not they're extremely noisy. This commit downgrades them to only show up when you look for them.
2026-04-29 13:03:00 -04:00

622 lines
23 KiB
Go

package handlers
import (
"archive/zip"
"bufio"
"context"
"errors"
"fmt"
"io"
"path/filepath"
"github.com/gabriel-vasile/mimetype"
"github.com/mholt/archives"
"google.golang.org/protobuf/proto"
logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
"github.com/trufflesecurity/trufflehog/v3/pkg/iobuf"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
)
// fileReader is a custom reader that wraps an io.Reader and provides additional functionality for identifying
// and handling different file types. It abstracts away the complexity of detecting file formats, MIME types,
// and archive types, allowing for a more modular and extensible file handling process.
//
// fileReader leverages the archiver and mimetype packages for file type identification and provides information
// about the detected file format, MIME type, and whether the file is an archive. This information can be
// used by FileHandler implementations to make decisions on how to process the file.
//
// The IsGenericArchive field indicates whether the file represents an archive format that is supported by the
// archiver library. This allows FileHandler implementations to determine if the file can be processed using
// the default archive handling capabilities provided by the archiver package.
//
// By encapsulating the file type detection logic, fileReader simplifies the implementation of FileHandler and
// promotes a more cohesive and maintainable codebase. It also embeds a BufferedFileReader to provide efficient
// random access to the file content.
type fileReader struct {
format archives.Format
mime *mimetype.MIME
isGenericArchive bool
*iobuf.BufferedReadSeeker
}
var (
ErrEmptyReader = errors.New("reader is empty")
// ErrProcessingFatal indicates a severe error that requires stopping the file processing.
ErrProcessingFatal = errors.New("fatal error processing file")
// ErrProcessingWarning indicates a recoverable error that can be logged,
// allowing processing to continue.
ErrProcessingWarning = errors.New("error processing file")
)
type readerConfig struct{ fileExtension string }
type readerOption func(*readerConfig)
func withFileExtension(ext string) readerOption {
return func(c *readerConfig) { c.fileExtension = ext }
}
// mimeTypeReader wraps an io.Reader with MIME type information.
// This type is used to pass content through the processing pipeline
// while carrying its detected MIME type, avoiding redundant type detection.
type mimeTypeReader struct {
mimeExt string
mimeName mimeType
io.Reader
}
// newMimeTypeReaderFromFileReader creates a new mimeTypeReader from a fileReader.
func newMimeTypeReaderFromFileReader(r fileReader) mimeTypeReader {
return mimeTypeReader{
mimeExt: r.mime.Extension(),
mimeName: mimeType(r.mime.String()),
Reader: r.BufferedReadSeeker,
}
}
// newMimeTypeReader creates a new mimeTypeReader from an io.Reader.
// It uses a bufio.Reader to perform MIME type detection on the input reader
// without consuming it, by peeking into the first 3072 bytes of the input.
// This encapsulates both the original reader and the detected MIME type information.
// This function is particularly useful for specialized archive handlers
// that need to pass extracted content to the default handler without modifying the original reader.
func newMimeTypeReader(r io.Reader) (mimeTypeReader, error) {
const defaultMinBufferSize = 3072
bufReader := bufio.NewReaderSize(r, defaultMinBufferSize)
// A buffer of 512 bytes is used since many file formats store their magic numbers within the first 512 bytes.
// If fewer bytes are read, MIME type detection may still succeed.
buffer, err := bufReader.Peek(defaultMinBufferSize)
if err != nil && !errors.Is(err, io.EOF) {
return mimeTypeReader{}, fmt.Errorf("unable to read file for MIME type detection: %w", err)
}
mime := mimetype.Detect(buffer)
return mimeTypeReader{mimeExt: mime.Extension(), mimeName: mimeType(mime.String()), Reader: bufReader}, nil
}
// newFileReader creates a fileReader from an io.Reader, optionally using BufferedFileWriter for certain formats.
// The caller is responsible for closing the reader when it is no longer needed.
func newFileReader(ctx context.Context, r io.Reader, options ...readerOption) (fReader fileReader, err error) {
var cfg readerConfig
for _, opt := range options {
opt(&cfg)
}
// To detect the MIME type of the input data, we need a reader that supports seeking.
// This allows us to read the data multiple times if necessary without losing the original position.
// We use a BufferedReaderSeeker to wrap the original reader, enabling this functionality.
fReader.BufferedReadSeeker = iobuf.NewBufferedReaderSeeker(r)
// If an error occurs during MIME type detection, it is important we close the BufferedReaderSeeker
// to release any resources it holds (checked out buffers or temp file).
defer func() {
if err != nil {
if closeErr := fReader.Close(); closeErr != nil {
err = fmt.Errorf("%w; error closing reader: %w", err, closeErr)
}
}
}()
var mime *mimetype.MIME
mime, err = mimetype.DetectReader(fReader)
if err != nil {
return fReader, fmt.Errorf("unable to detect MIME type: %w", err)
}
fReader.mime = mime
// Reset the reader to the beginning because DetectReader consumes the reader.
if _, err = fReader.Seek(0, io.SeekStart); err != nil {
return fReader, fmt.Errorf("error resetting reader after MIME detection: %w", err)
}
// Check for APK files
if shouldHandleAsAPK(cfg, fReader) {
isAPK, err := isAPKFile(&fReader)
if err != nil {
return fReader, fmt.Errorf("error checking for APK: %w", err)
}
if isAPK {
return handleAPKFile(&fReader)
}
}
// If a MIME type is known to not be an archive type, we might as well return here rather than
// paying the I/O penalty of an archiver.Identify() call that won't identify anything.
if _, ok := skipArchiverMimeTypes[mimeType(mime.String())]; ok {
return fReader, nil
}
var format archives.Format
format, _, err = archives.Identify(ctx, "", fReader)
switch {
case err == nil:
fReader.isGenericArchive = true
fReader.format = format
case errors.Is(err, archives.NoMatch):
// Not an archive handled by archiver.
// Continue with the default reader.
default:
return fReader, fmt.Errorf("error identifying archive: %w", err)
}
// Reset the reader to the beginning again to allow the handler to read from the start.
// This is necessary because Identify consumes the reader.
if _, err = fReader.Seek(0, io.SeekStart); err != nil {
return fReader, fmt.Errorf("error resetting reader after archive identification: %w", err)
}
return fReader, nil
}
// DataOrErr represents a result that can either contain data or an error.
// The Data field holds the byte slice of data, and the Err field holds any error that occurred.
// This structure is used to handle asynchronous file processing where each chunk of data
// or potential error needs to be communicated back to the caller. It allows for
// efficient streaming of file contents while also providing a way to propagate errors
// that may occur during the file handling process.
type DataOrErr struct {
Data []byte
Err error
// LineNumber is the 1-indexed starting line of this data within the file.
LineNumber int64
}
// FileHandler represents a handler for files.
// It has a single method, HandleFile, which takes a context and a fileReader as input,
// and returns a channel of byte slices and an error.
type FileHandler interface {
HandleFile(ctx logContext.Context, reader fileReader) chan DataOrErr
}
// fileHandlingConfig encapsulates configuration settings that control the behavior of file processing.
type fileHandlingConfig struct{ skipArchives bool }
// newFileHandlingConfig creates a default fileHandlingConfig with default settings.
// Optional functional parameters can customize the configuration.
func newFileHandlingConfig(options ...func(*fileHandlingConfig)) fileHandlingConfig {
config := fileHandlingConfig{}
for _, option := range options {
option(&config)
}
return config
}
// WithSkipArchives sets the skipArchives field of the fileHandlingConfig.
// If skip is true, the FileHandler will skip archive files.
func WithSkipArchives(skip bool) func(*fileHandlingConfig) {
return func(c *fileHandlingConfig) { c.skipArchives = skip }
}
type handlerType string
const (
archiveHandlerType handlerType = "archive"
arHandlerType handlerType = "ar"
rpmHandlerType handlerType = "rpm"
apkHandlerType handlerType = "apk"
defaultHandlerType handlerType = "default"
apkExt = ".apk"
)
type mimeType string
const (
rpmMime mimeType = "application/x-rpm"
cpioMime mimeType = "application/cpio"
unixArMime mimeType = "application/x-unix-archive"
arMime mimeType = "application/x-archive"
debMime mimeType = "application/vnd.debian.binary-package"
textMime mimeType = "text/plain; charset=utf-8"
xmlMime mimeType = "text/xml"
jsonMime mimeType = "application/json"
csvMime mimeType = "text/csv"
tsvMime mimeType = "text/tab-separated-values"
geoJSONMine mimeType = "application/vnd.geo+json"
ndjsonMime mimeType = "application/x-ndjson"
htmlMime mimeType = "text/html"
phpTextMime mimeType = "text/x-php"
rtfTextMime mimeType = "text/rtf"
jsAppMime mimeType = "application/javascript"
jsTextMime mimeType = "text/javascript"
jsMime mimeType = "application/x-javascript"
srtMime mimeType = "application/x-subrip"
srtXMime mimeType = "application/x-srt"
srtTextMime mimeType = "text/x-srt"
vttMime mimeType = "text/vtt"
luaMime mimeType = "text/x-lua"
perlMime mimeType = "text/x-perl"
pythonMime mimeType = "text/x-python"
pyAppMime mimeType = "application/x-python"
pyScriptMime mimeType = "application/x-script.python"
tclTextMime mimeType = "text/x-tcl"
tclMime mimeType = "application/x-tcl"
apkMime mimeType = "application/vnd.android.package-archive"
zipMime mimeType = "application/zip"
jarMime mimeType = "application/java-archive"
msgMime mimeType = "application/vnd.ms-outlook"
docMime mimeType = "application/msword"
)
// skipArchiverMimeTypes is a set of MIME types that should bypass archiver library processing because they are either
// text-based or archives not supported by the library.
var skipArchiverMimeTypes = map[mimeType]struct{}{
arMime: {},
unixArMime: {},
debMime: {},
rpmMime: {},
cpioMime: {},
textMime: {},
xmlMime: {},
jsonMime: {},
csvMime: {},
tsvMime: {},
geoJSONMine: {},
ndjsonMime: {},
htmlMime: {},
phpTextMime: {},
rtfTextMime: {},
jsAppMime: {},
jsTextMime: {},
jsMime: {},
srtMime: {},
srtXMime: {},
srtTextMime: {},
vttMime: {},
luaMime: {},
perlMime: {},
pythonMime: {},
pyAppMime: {},
pyScriptMime: {},
tclTextMime: {},
tclMime: {},
apkMime: {},
msgMime: {},
docMime: {},
}
// selectHandler dynamically selects and configures a FileHandler based on the provided |mimetype| type and archive flag.
// The fileReader contains information about the MIME type and whether the file is an archive.
// This method uses specialized handlers for specific file types:
// - arHandler is used for Unix archives and Debian packages ('arMime', 'unixArMime', and 'debMime').
// - rpmHandler is used for RPM and CPIO archives ('rpmMime' and 'cpioMime').
// - apkHandler is used for APK archives ('apkMime').
// - archiveHandler is used for common archive formats supported by the archiver library (.zip, .tar, .gz, etc.).
// - defaultHandler is used for non-archive files.
// The selected handler is then returned, ready to handle the file according to its specific format and requirements.
func selectHandler(mimeT mimeType, isGenericArchive bool) FileHandler {
switch mimeT {
case arMime, unixArMime, debMime:
return newARHandler()
case rpmMime, cpioMime:
return newRPMHandler()
case apkMime:
return newAPKHandler()
default:
if isGenericArchive {
return newArchiveHandler()
}
return newDefaultHandler(defaultHandlerType)
}
}
// HandleFile orchestrates the complete file handling process for a given file.
// It determines the MIME type of the file,
// selects the appropriate handler based on this type, and processes the file.
// This function initializes the handling process and delegates to the specific
// handler to manage file extraction or processing.
//
// The function will return nil (success) in the following cases:
// - If the reader is empty (ErrEmptyReader)
// - If skipArchives option is true and the file is detected as an archive
// - If all chunks are processed successfully without critical errors
//
// The function will return an error in the following cases:
// - If the reader is nil
// - If there's an error creating the file reader
// - If there's an error closing the reader
// - If a critical error occurs during chunk processing (context cancellation, deadline exceeded, or ErrProcessingFatal)
// - If there's an error reporting a chunk
//
// Non-critical errors during chunk processing are logged
// but do not cause the function to return an error.
func HandleFile(
ctx logContext.Context,
reader io.Reader,
chunkSkel *sources.Chunk,
reporter sources.ChunkReporter,
options ...func(*fileHandlingConfig),
) error {
if reader == nil {
return errors.New("reader is nil")
}
readerOption := withFileExtension(getFileExtension(chunkSkel))
rdr, err := newFileReader(ctx, reader, readerOption)
if err != nil {
if errors.Is(err, ErrEmptyReader) {
ctx.Logger().V(5).Info("empty reader, skipping file")
return nil
}
return fmt.Errorf("unable to HandleFile, error creating file reader: %w", err)
}
defer func() {
// Ensure all data is read to prevent broken pipe.
if closeErr := rdr.Close(); closeErr != nil {
if err != nil {
err = errors.Join(err, closeErr)
} else {
err = fmt.Errorf("error closing reader: %w", closeErr)
}
}
}()
ctx = logContext.WithValues(ctx, "mime", rdr.mime.String())
mimeT := mimeType(rdr.mime.String())
config := newFileHandlingConfig(options...)
if config.skipArchives && rdr.isGenericArchive {
ctx.Logger().V(5).Info("skipping archive file", "mime", mimeT)
return nil
}
processingCtx, cancel := logContext.WithTimeout(ctx, maxTimeout)
defer cancel()
handler := selectHandler(mimeT, rdr.isGenericArchive)
dataOrErrChan := handler.HandleFile(processingCtx, rdr) // Delegate to the specific handler to process the file.
return handleChunksWithError(processingCtx, dataOrErrChan, chunkSkel, reporter)
}
// handleChunksWithError processes data and errors received from the dataErrChan channel.
// For each DataOrErr received:
// - If it contains data, the function creates a chunk based on chunkSkel and reports it through the reporter.
// - If it contains an error, the function handles it based on severity:
// - Fatal errors (context cancellation, deadline exceeded, ErrProcessingFatal) cause immediate termination
// - Non-fatal errors (ErrProcessingWarning and others) are logged and processing continues
//
// The function also listens for context cancellation to gracefully terminate processing if the context is done.
// It returns nil upon successful processing of all data, or the first encountered fatal error.
// Line numbers from DataOrErr are propagated to the chunk's source metadata for accurate reporting.
func handleChunksWithError(
ctx logContext.Context,
dataErrChan <-chan DataOrErr,
chunkSkel *sources.Chunk,
reporter sources.ChunkReporter,
) error {
for {
select {
case dataOrErr, ok := <-dataErrChan:
if !ok {
// Channel closed, processing complete.
ctx.Logger().V(5).Info("dataErrChan closed, all chunks processed")
return nil
}
if dataOrErr.Err != nil {
if isFatal(dataOrErr.Err) {
return dataOrErr.Err
}
ctx.Logger().V(2).Info("non-critical error processing chunk", "error", dataOrErr.Err)
continue
}
if len(dataOrErr.Data) > 0 {
chunk := *chunkSkel
chunk.Data = dataOrErr.Data
populateChunkLineNumber(&chunk, dataOrErr.LineNumber)
if err := reporter.ChunkOk(ctx, chunk); err != nil {
return fmt.Errorf("error reporting chunk: %w", err)
}
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// populateChunkLineNumber sets the line number in the chunk's source metadata.
// It clones the metadata to avoid sharing state between chunks, since chunk
// skeletons reuse the same metadata pointer. Line number is expected to be 1-indexed.
// If lineNumber is 0 or metadata is nil, no changes are made.
func populateChunkLineNumber(chunk *sources.Chunk, lineNumber int64) {
if lineNumber == 0 || chunk.SourceMetadata == nil {
return
}
cloned := proto.CloneOf(chunk.SourceMetadata)
switch m := cloned.Data.(type) {
case *source_metadatapb.MetaData_Filesystem:
m.Filesystem.Line = lineNumber
case *source_metadatapb.MetaData_AzureRepos:
m.AzureRepos.Line = lineNumber
case *source_metadatapb.MetaData_Bitbucket:
m.Bitbucket.Line = lineNumber
case *source_metadatapb.MetaData_Gerrit:
m.Gerrit.Line = lineNumber
case *source_metadatapb.MetaData_Github:
m.Github.Line = lineNumber
case *source_metadatapb.MetaData_Gitlab:
m.Gitlab.Line = lineNumber
case *source_metadatapb.MetaData_Git:
m.Git.Line = lineNumber
case *source_metadatapb.MetaData_Huggingface:
m.Huggingface.Line = lineNumber
}
chunk.SourceMetadata = cloned
}
// isFatal determines whether the given error is a fatal error that should
// terminate processing the current file, or a non-critical error that can be logged and ignored.
// "Fatal" errors include context cancellation, deadline exceeded, and the
// ErrProcessingFatal error. Non-fatal errors include the ErrProcessingWarning
// error as well as any other error that is not one of the fatal errors.
func isFatal(err error) bool {
switch {
case errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, ErrProcessingFatal):
return true
case errors.Is(err, ErrProcessingWarning):
return false
default:
return false
}
}
// getFileExtension extracts the file extension from the chunk's SourceMetadata.
// It considers all sources defined in the MetaData message.
// Note: Probably should add this as a method to the source_metadatapb object.
// then it'd just be chunkSkel.SourceMetadata.GetFileExtension()
func getFileExtension(chunkSkel *sources.Chunk) string {
if chunkSkel == nil || chunkSkel.SourceMetadata == nil {
return ""
}
var fileName string
// Inspect the SourceMetadata to determine the source type
switch metadata := chunkSkel.SourceMetadata.Data.(type) {
case *source_metadatapb.MetaData_Artifactory:
fileName = metadata.Artifactory.Path
case *source_metadatapb.MetaData_Azure:
fileName = metadata.Azure.File
case *source_metadatapb.MetaData_AzureRepos:
fileName = metadata.AzureRepos.File
case *source_metadatapb.MetaData_Bitbucket:
fileName = metadata.Bitbucket.File
case *source_metadatapb.MetaData_Buildkite:
fileName = metadata.Buildkite.Link
case *source_metadatapb.MetaData_Circleci:
fileName = metadata.Circleci.Link
case *source_metadatapb.MetaData_Confluence:
fileName = metadata.Confluence.File
case *source_metadatapb.MetaData_Docker:
fileName = metadata.Docker.File
case *source_metadatapb.MetaData_Ecr:
fileName = metadata.Ecr.File
case *source_metadatapb.MetaData_Filesystem:
fileName = metadata.Filesystem.File
case *source_metadatapb.MetaData_Git:
fileName = metadata.Git.File
case *source_metadatapb.MetaData_Github:
fileName = metadata.Github.File
case *source_metadatapb.MetaData_Gitlab:
fileName = metadata.Gitlab.File
case *source_metadatapb.MetaData_Gcs:
fileName = metadata.Gcs.Filename
case *source_metadatapb.MetaData_GoogleDrive:
fileName = metadata.GoogleDrive.File
case *source_metadatapb.MetaData_Huggingface:
fileName = metadata.Huggingface.File
case *source_metadatapb.MetaData_Jira:
fileName = metadata.Jira.Link
case *source_metadatapb.MetaData_Jenkins:
fileName = metadata.Jenkins.Link
case *source_metadatapb.MetaData_Npm:
fileName = metadata.Npm.File
case *source_metadatapb.MetaData_Pypi:
fileName = metadata.Pypi.File
case *source_metadatapb.MetaData_S3:
fileName = metadata.S3.File
case *source_metadatapb.MetaData_Slack:
fileName = metadata.Slack.File
case *source_metadatapb.MetaData_Sharepoint:
fileName = metadata.Sharepoint.Link
case *source_metadatapb.MetaData_Gerrit:
fileName = metadata.Gerrit.File
case *source_metadatapb.MetaData_Test:
fileName = metadata.Test.File
case *source_metadatapb.MetaData_Teams:
fileName = metadata.Teams.File
case *source_metadatapb.MetaData_TravisCI:
fileName = metadata.TravisCI.Link
// Add other sources if they have a file or equivalent field
// Skipping Syslog, Forager, Postman, Vector, Webhook and Elasticsearch
default:
return ""
}
// Use filepath.Ext to extract the file extension from the file name
ext := filepath.Ext(fileName)
return ext
}
// shouldHandleAsAPK checks if the file should be handled as an APK based on config and MIME type.
// Note: We can't extend the mimetype package with an APK detection function b/c it would require adjusting settings
// so that all files are fully read into a byte slice for detection (mimetype.SetLimit(0)), which would bloat memory.
// Instead we call the isAPKFile function in here after ensuring it's a zip/jar file and has an .apk extension.
func shouldHandleAsAPK(cfg readerConfig, fReader fileReader) bool {
return feature.EnableAPKHandler.Load() &&
cfg.fileExtension == apkExt &&
(fReader.mime.String() == string(zipMime) || fReader.mime.String() == string(jarMime))
}
func isAPKFile(r *fileReader) (bool, error) {
size, _ := r.Size()
zipReader, err := zip.NewReader(r, size)
if err != nil {
return false, fmt.Errorf("error creating zip reader: %w", err)
}
hasManifest := false
hasClasses := false
for _, file := range zipReader.File {
switch file.Name {
case "AndroidManifest.xml":
hasManifest = true
case "classes.dex":
hasClasses = true
default:
// Skip other files.
}
if hasManifest && hasClasses {
return true, nil
}
}
return false, nil
}
// handleAPKFile configures the MIME type for an APK and resets the reader.
func handleAPKFile(fReader *fileReader) (fileReader, error) {
// Extend the MIME type to recognize APK files
mimetype.Lookup("application/zip").Extend(func(r []byte, l uint32) bool { return false }, string(apkMime), ".apk")
fReader.mime = mimetype.Lookup(string(apkMime))
// Reset reader for further handling
if _, err := fReader.Seek(0, io.SeekStart); err != nil {
return *fReader, fmt.Errorf("error resetting reader after APK detection: %w", err)
}
return *fReader, nil
}