885 lines
30 KiB
Go
885 lines
30 KiB
Go
package s3
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/aws"
|
||
"github.com/aws/aws-sdk-go-v2/config"
|
||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||
s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||
"github.com/go-errors/errors"
|
||
"golang.org/x/sync/errgroup"
|
||
"google.golang.org/protobuf/proto"
|
||
"google.golang.org/protobuf/types/known/anypb"
|
||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/handlers"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/log"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/sanitizer"
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
||
)
|
||
|
||
const (
|
||
SourceType = sourcespb.SourceType_SOURCE_TYPE_S3
|
||
|
||
defaultAWSRegion = "us-east-1"
|
||
defaultMaxObjectSize = 250 * 1024 * 1024 // 250 MiB
|
||
maxObjectSizeLimit = 250 * 1024 * 1024 // 250 MiB
|
||
)
|
||
|
||
type Source struct {
|
||
name string
|
||
sourceID sources.SourceID
|
||
jobID sources.JobID
|
||
verify bool
|
||
concurrency int
|
||
conn *sourcespb.S3
|
||
|
||
sources.Progress
|
||
metricsCollector metricsCollector
|
||
|
||
errorCount *sync.Map
|
||
jobPool *errgroup.Group
|
||
maxObjectSize int64
|
||
// endpoint is the S3-compatible service to scan, or nil for AWS S3.
|
||
endpoint *url.URL
|
||
}
|
||
|
||
// Ensure the Source satisfies the interfaces at compile time
|
||
var _ sources.Source = (*Source)(nil)
|
||
var _ sources.SourceUnitUnmarshaller = (*Source)(nil)
|
||
var _ sources.Validator = (*Source)(nil)
|
||
var _ sources.SourceUnitEnumChunker = (*Source)(nil)
|
||
|
||
// Type returns the type of source
|
||
func (s *Source) Type() sourcespb.SourceType { return SourceType }
|
||
|
||
func (s *Source) SourceID() sources.SourceID { return s.sourceID }
|
||
|
||
func (s *Source) JobID() sources.JobID { return s.jobID }
|
||
|
||
// Init returns an initialized AWS source
|
||
func (s *Source) Init(
|
||
ctx context.Context,
|
||
name string,
|
||
jobID sources.JobID,
|
||
sourceID sources.SourceID,
|
||
verify bool,
|
||
connection *anypb.Any,
|
||
concurrency int,
|
||
) error {
|
||
s.name = name
|
||
s.sourceID = sourceID
|
||
s.jobID = jobID
|
||
s.verify = verify
|
||
s.concurrency = concurrency
|
||
s.errorCount = &sync.Map{}
|
||
s.jobPool = &errgroup.Group{}
|
||
s.jobPool.SetLimit(concurrency)
|
||
|
||
var conn sourcespb.S3
|
||
if err := anypb.UnmarshalTo(connection, &conn, proto.UnmarshalOptions{}); err != nil {
|
||
return fmt.Errorf("error unmarshalling connection: %w", err)
|
||
}
|
||
s.conn = &conn
|
||
|
||
endpoint, err := normalizeEndpoint(conn.GetEndpoint())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.endpoint = endpoint
|
||
|
||
s.metricsCollector = metricsInstance
|
||
|
||
s.setMaxObjectSize(conn.GetMaxObjectSize())
|
||
|
||
if len(conn.GetBuckets()) > 0 && len(conn.GetIgnoreBuckets()) > 0 {
|
||
return errors.New("either a bucket include list or a bucket ignore list can be specified, but not both")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *Source) Validate(ctx context.Context) []error {
|
||
var errs []error
|
||
visitor := func(c context.Context, defaultRegionClient *s3.Client, roleArn string, buckets []string) error {
|
||
roleErrs := s.validateBucketAccess(c, defaultRegionClient, roleArn, buckets)
|
||
if len(roleErrs) > 0 {
|
||
errs = append(errs, roleErrs...)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
if err := s.visitRoles(ctx, visitor); err != nil {
|
||
errs = append(errs, err)
|
||
}
|
||
|
||
return errs
|
||
}
|
||
|
||
// setMaxObjectSize sets the maximum size of objects that will be scanned. If
|
||
// not set, set to a negative number, or set larger than the
|
||
// maxObjectSizeLimit, the defaultMaxObjectSizeLimit will be used.
|
||
func (s *Source) setMaxObjectSize(maxObjectSize int64) {
|
||
if maxObjectSize <= 0 || maxObjectSize > maxObjectSizeLimit {
|
||
s.maxObjectSize = defaultMaxObjectSize
|
||
} else {
|
||
s.maxObjectSize = maxObjectSize
|
||
}
|
||
}
|
||
|
||
// normalizeEndpoint parses the configured endpoint of an S3-compatible
|
||
// service, returning nil for the empty endpoint that means AWS S3. A scheme is
|
||
// assumed when one is missing, because the endpoint is user-entered and the AWS
|
||
// SDK rejects a bare host with an error that does not say so.
|
||
func normalizeEndpoint(endpoint string) (*url.URL, error) {
|
||
if endpoint == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
parsed, err := url.Parse(endpoint)
|
||
|
||
// Without a scheme, the endpoint either parses as a path with no host or,
|
||
// when it carries a port, fails to parse at all. Retry those as URLs, but
|
||
// only when no scheme was given, so that a malformed one is still reported
|
||
// rather than buried under a second scheme.
|
||
if !strings.Contains(endpoint, "://") && (err != nil || parsed.Host == "") {
|
||
parsed, err = url.Parse("https://" + endpoint)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("could not parse endpoint %q: %w", endpoint, err)
|
||
}
|
||
|
||
if parsed.Host == "" {
|
||
return nil, fmt.Errorf("endpoint %q has no host; expected something like https://s3.internal.example.com", endpoint)
|
||
}
|
||
|
||
return parsed, nil
|
||
}
|
||
|
||
// defaultRegion returns the region used to sign requests for buckets whose own
|
||
// region has not been discovered.
|
||
func (s *Source) defaultRegion() string {
|
||
if region := s.conn.GetRegion(); region != "" {
|
||
return region
|
||
}
|
||
return defaultAWSRegion
|
||
}
|
||
|
||
func (s *Source) newClient(ctx context.Context, region, roleArn string) (*s3.Client, error) {
|
||
var credsProvider aws.CredentialsProvider
|
||
switch cred := s.conn.GetCredential().(type) {
|
||
case *sourcespb.S3_SessionToken:
|
||
credsProvider = credentials.NewStaticCredentialsProvider(
|
||
cred.SessionToken.GetKey(),
|
||
cred.SessionToken.GetSecret(),
|
||
cred.SessionToken.GetSessionToken(),
|
||
)
|
||
log.RedactGlobally(cred.SessionToken.GetSecret())
|
||
log.RedactGlobally(cred.SessionToken.GetSessionToken())
|
||
case *sourcespb.S3_AccessKey:
|
||
credsProvider = credentials.NewStaticCredentialsProvider(cred.AccessKey.GetKey(), cred.AccessKey.GetSecret(), "")
|
||
log.RedactGlobally(cred.AccessKey.GetSecret())
|
||
case *sourcespb.S3_Unauthenticated:
|
||
credsProvider = aws.AnonymousCredentials{}
|
||
default:
|
||
// In all other cases, the AWS SDK will follow its normal waterfall logic to pick up credentials (i.e. they can
|
||
// come from the environment or the credentials file or whatever else AWS gets up to).
|
||
}
|
||
|
||
if roleArn != "" {
|
||
// The config loaded here will be used to retrieve and refresh temporary credentials from AssumeRole
|
||
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region), config.WithCredentialsProvider(credsProvider))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
stsClient := sts.NewFromConfig(cfg)
|
||
provider := stscreds.NewAssumeRoleProvider(stsClient, roleArn, func(options *stscreds.AssumeRoleOptions) {
|
||
options.RoleSessionName = "trufflehog"
|
||
})
|
||
// From https://docs.aws.amazon.com/sdk-for-go/v2/developer-guide/configure-gosdk.html#specify-credentials-programmatically:
|
||
// "If you explicitly configure a provider on aws.Config directly,
|
||
// you must also explicitly wrap the provider with this type using NewCredentialsCache"
|
||
credsProvider = aws.NewCredentialsCache(provider)
|
||
}
|
||
|
||
cfg, err := config.LoadDefaultConfig(
|
||
ctx,
|
||
config.WithRegion(region),
|
||
config.WithCredentialsProvider(credsProvider),
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s3.NewFromConfig(cfg, func(options *s3.Options) {
|
||
options.DisableLogOutputChecksumValidationSkipped = true
|
||
if s.endpoint != nil {
|
||
options.BaseEndpoint = aws.String(s.endpoint.String())
|
||
// S3-compatible services rarely publish the wildcard DNS that
|
||
// virtual-hosted addressing needs.
|
||
options.UsePathStyle = true
|
||
}
|
||
}), nil
|
||
}
|
||
|
||
// getBucketsToScan returns a list of S3 buckets to scan.
|
||
// If the connection has a list of buckets specified, those are returned.
|
||
// Otherwise, it lists all buckets the client has access to and filters out the ignored ones.
|
||
// The list of buckets is sorted lexicographically to ensure consistent ordering,
|
||
// which allows resuming scanning from the same place if the scan is interrupted.
|
||
//
|
||
// Note: The IAM identity needs the s3:ListBuckets permission.
|
||
func (s *Source) getBucketsToScan(ctx context.Context, client *s3.Client) ([]string, error) {
|
||
if buckets := s.conn.GetBuckets(); len(buckets) > 0 {
|
||
slices.Sort(buckets)
|
||
return buckets, nil
|
||
}
|
||
|
||
ignore := make(map[string]struct{}, len(s.conn.GetIgnoreBuckets()))
|
||
for _, bucket := range s.conn.GetIgnoreBuckets() {
|
||
ignore[bucket] = struct{}{}
|
||
}
|
||
|
||
res, err := client.ListBuckets(ctx, &s3.ListBucketsInput{})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var bucketsToScan []string
|
||
for _, bucket := range res.Buckets {
|
||
name := *bucket.Name
|
||
if _, ignored := ignore[name]; !ignored {
|
||
bucketsToScan = append(bucketsToScan, name)
|
||
}
|
||
}
|
||
slices.Sort(bucketsToScan)
|
||
|
||
return bucketsToScan, nil
|
||
}
|
||
|
||
// pageMetadata contains metadata about a single page of S3 objects being scanned.
|
||
type pageMetadata struct {
|
||
bucket string // The name of the S3 bucket being scanned
|
||
role string // The AWS role ARN used for scanning
|
||
pageNumber int // Current page number in the pagination sequence
|
||
client *s3.Client // AWS S3 client configured for the appropriate region
|
||
page *s3.ListObjectsV2Output // Contains the list of S3 objects in this page
|
||
}
|
||
|
||
// processingState tracks the state of concurrent S3 object processing.
|
||
type processingState struct {
|
||
errorCount *sync.Map // Thread-safe map tracking errors per prefix
|
||
objectCount *uint64 // Total number of objects processed
|
||
}
|
||
|
||
// resumePosition tracks where to restart scanning S3 buckets and objects after an interruption.
|
||
// It encapsulates all the information needed to resume a scan from its last known position.
|
||
type resumePosition struct {
|
||
bucket string // The bucket name we were processing
|
||
index int // Index in the buckets slice where we should resume
|
||
startAfter string // The last processed object key within the bucket
|
||
isNewScan bool // True if we're starting a fresh scan
|
||
exactMatch bool // True if we found the exact bucket we were previously processing
|
||
role string // The role used during the previous scan
|
||
}
|
||
|
||
// determineResumePosition calculates where to resume scanning from based on the last saved checkpoint
|
||
// and the current list of available buckets to scan. It handles several scenarios:
|
||
//
|
||
// 1. If getting the resume point fails or there is no previous bucket saved (CurrentBucket is empty),
|
||
// we start a new scan from the beginning, this is the safest option.
|
||
//
|
||
// 2. If the previous bucket exists in our current scan list (exactMatch=true),
|
||
// we resume from that exact position and use the StartAfter value
|
||
// to continue from the last processed object within that bucket.
|
||
//
|
||
// 3. If the previous bucket is not found in our current scan list (exactMatch=false), this typically means:
|
||
// - The bucket was deleted since our last scan
|
||
// - The bucket was explicitly excluded from this scan's configuration
|
||
// - The IAM role no longer has access to the bucket
|
||
// - The bucket name changed due to a configuration update
|
||
// In this case, we use binary search to find the closest position where the bucket would have been,
|
||
// allowing us to resume from the nearest available point in our sorted bucket list rather than
|
||
// restarting the entire scan.
|
||
func determineResumePosition(ctx context.Context, tracker *Checkpointer, buckets []string) resumePosition {
|
||
resumePoint, err := tracker.ResumePoint(ctx)
|
||
if err != nil {
|
||
ctx.Logger().Error(err, "failed to get resume point; starting from the beginning")
|
||
return resumePosition{isNewScan: true}
|
||
}
|
||
|
||
if resumePoint.CurrentBucket == "" {
|
||
return resumePosition{isNewScan: true}
|
||
}
|
||
|
||
startIdx, found := slices.BinarySearch(buckets, resumePoint.CurrentBucket)
|
||
return resumePosition{
|
||
bucket: resumePoint.CurrentBucket,
|
||
startAfter: resumePoint.StartAfter,
|
||
index: startIdx,
|
||
exactMatch: found,
|
||
role: resumePoint.Role,
|
||
}
|
||
}
|
||
|
||
// scanBuckets scans the given buckets using the given role and adds the number
|
||
// of objects it scanned to totalObjectCount. The counter is owned by Chunks and
|
||
// shared across role passes so that the completion message reflects the whole
|
||
// scan, not just the last role's pass.
|
||
func (s *Source) scanBuckets(
|
||
ctx context.Context,
|
||
client *s3.Client,
|
||
role string,
|
||
bucketsToScan []string,
|
||
chunksChan chan *sources.Chunk,
|
||
totalObjectCount *uint64,
|
||
) {
|
||
if role != "" {
|
||
ctx = context.WithValue(ctx, "role", role)
|
||
}
|
||
|
||
checkpointer := NewCheckpointer(ctx, &s.Progress, false)
|
||
pos := determineResumePosition(ctx, checkpointer, bucketsToScan)
|
||
switch {
|
||
case pos.isNewScan:
|
||
ctx.Logger().Info("Starting new scan from beginning")
|
||
case !pos.exactMatch:
|
||
ctx.Logger().Info(
|
||
"Resume bucket no longer available, starting from closest position",
|
||
"original_bucket", pos.bucket,
|
||
"position", pos.index,
|
||
"role", pos.role,
|
||
)
|
||
default:
|
||
ctx.Logger().Info(
|
||
"Resuming scan from previous scan's bucket",
|
||
"bucket", pos.bucket,
|
||
"position", pos.index,
|
||
"role", pos.role,
|
||
)
|
||
}
|
||
|
||
bucketsToScanCount := len(bucketsToScan)
|
||
for bucketIdx := pos.index; bucketIdx < bucketsToScanCount; bucketIdx++ {
|
||
bucket := bucketsToScan[bucketIdx]
|
||
|
||
s.SetProgressComplete(
|
||
bucketIdx,
|
||
len(bucketsToScan),
|
||
fmt.Sprintf("Bucket: %s", bucket),
|
||
s.EncodedResumeInfo,
|
||
)
|
||
|
||
var startAfter *string
|
||
if bucket == pos.bucket && pos.startAfter != "" && role == pos.role {
|
||
startAfter = &pos.startAfter
|
||
ctx.Logger().V(3).Info(
|
||
"Resuming bucket scan",
|
||
"start_after", pos.startAfter,
|
||
"bucket", bucket,
|
||
)
|
||
}
|
||
|
||
objectCount := s.scanBucket(ctx, client, role, bucket, sources.ChanReporter{Ch: chunksChan}, startAfter, checkpointer)
|
||
*totalObjectCount += objectCount
|
||
}
|
||
|
||
s.SetProgressComplete(
|
||
len(bucketsToScan),
|
||
len(bucketsToScan),
|
||
fmt.Sprintf("Completed scanning source %s. %d objects scanned.", s.name, *totalObjectCount),
|
||
"",
|
||
)
|
||
}
|
||
|
||
func (s *Source) scanBucket(
|
||
ctx context.Context,
|
||
client *s3.Client,
|
||
role string,
|
||
bucket string,
|
||
reporter sources.ChunkReporter,
|
||
startAfter *string,
|
||
checkpointer *Checkpointer,
|
||
) uint64 {
|
||
s.metricsCollector.RecordBucketForRole(role)
|
||
|
||
ctx = context.WithValue(ctx, "bucket", bucket)
|
||
|
||
if common.IsDone(ctx) {
|
||
ctx.Logger().Error(ctx.Err(), "context done, while scanning bucket")
|
||
return 0
|
||
}
|
||
|
||
ctx.Logger().V(3).Info("Scanning bucket")
|
||
|
||
regionalClient, err := s.getRegionalClientForBucket(ctx, client, role, bucket)
|
||
if err != nil {
|
||
ctx.Logger().Error(err, "could not get regional client for bucket")
|
||
return 0
|
||
}
|
||
|
||
errorCount := sync.Map{}
|
||
|
||
input := &s3.ListObjectsV2Input{Bucket: &bucket}
|
||
if startAfter != nil {
|
||
input.StartAfter = startAfter
|
||
}
|
||
|
||
pageNumber := 1
|
||
paginator := s3.NewListObjectsV2Paginator(regionalClient, input)
|
||
var objectCount uint64
|
||
for paginator.HasMorePages() {
|
||
output, err := paginator.NextPage(ctx)
|
||
if err != nil {
|
||
if s.listErrorsAreExpected(role) {
|
||
// Our documentation blesses specifying a role to assume without specifying buckets to scan, which will
|
||
// often cause this to happen a lot (because in that case the scanner tries to scan every bucket in the
|
||
// account, but the role probably doesn't have access to all of them). This makes it expected behavior
|
||
// and therefore not an error.
|
||
ctx.Logger().V(3).Info("could not list objects in bucket", "err", err)
|
||
} else {
|
||
// This can also be a failure to assume the role itself: role credentials
|
||
// are resolved lazily, so the first request that needs them surfaces the
|
||
// STS error here rather than at client construction.
|
||
ctx.Logger().Error(err, "could not list objects in bucket")
|
||
}
|
||
s.metricsCollector.RecordBucketListError(bucket, role)
|
||
break
|
||
}
|
||
pageMetadata := pageMetadata{
|
||
bucket: bucket,
|
||
role: role,
|
||
pageNumber: pageNumber,
|
||
client: regionalClient,
|
||
page: output,
|
||
}
|
||
processingState := processingState{
|
||
errorCount: &errorCount,
|
||
objectCount: &objectCount,
|
||
}
|
||
s.pageChunker(ctx, pageMetadata, processingState, reporter, checkpointer)
|
||
|
||
pageNumber++
|
||
}
|
||
return objectCount
|
||
}
|
||
|
||
// listErrorsAreExpected reports whether a failure to list a bucket's objects
|
||
// should be suppressed rather than logged as an error. When a role is assumed
|
||
// without an explicit bucket list, the scanner attempts every bucket in the
|
||
// account and is expected to be denied on some of them. When buckets are
|
||
// explicitly configured, a listing failure means a configured target is being
|
||
// silently skipped, so it is always an error, even under an assumed role.
|
||
func (s *Source) listErrorsAreExpected(role string) bool {
|
||
return role != "" && len(s.conn.GetBuckets()) == 0
|
||
}
|
||
|
||
// Chunks emits chunks of bytes over a channel.
|
||
func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ ...sources.ChunkingTarget) error {
|
||
var totalObjectCount uint64
|
||
visitor := func(c context.Context, defaultRegionClient *s3.Client, roleArn string, buckets []string) error {
|
||
s.scanBuckets(c, defaultRegionClient, roleArn, buckets, chunksChan, &totalObjectCount)
|
||
return nil
|
||
}
|
||
|
||
return s.visitRoles(ctx, visitor)
|
||
}
|
||
|
||
func (s *Source) getRegionalClientForBucket(
|
||
ctx context.Context,
|
||
defaultRegionClient *s3.Client,
|
||
role string,
|
||
bucket string,
|
||
) (*s3.Client, error) {
|
||
// GetBucketRegion is an AWS-only API, and a custom endpoint serves every
|
||
// bucket itself, so there is no per-bucket region to discover.
|
||
if s.endpoint != nil {
|
||
return defaultRegionClient, nil
|
||
}
|
||
|
||
region, err := s3manager.GetBucketRegion(ctx, defaultRegionClient, bucket)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("could not get s3 region for bucket: %s: %w", bucket, err)
|
||
}
|
||
|
||
if region == s.defaultRegion() {
|
||
return defaultRegionClient, nil
|
||
}
|
||
|
||
regionalClient, err := s.newClient(ctx, region, role)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("could not create regional s3 client for bucket %s: %w", bucket, err)
|
||
}
|
||
|
||
return regionalClient, nil
|
||
}
|
||
|
||
// pageChunker emits chunks onto the given channel from a page.
|
||
func (s *Source) pageChunker(
|
||
ctx context.Context,
|
||
metadata pageMetadata,
|
||
state processingState,
|
||
reporter sources.ChunkReporter,
|
||
checkpointer *Checkpointer,
|
||
) {
|
||
checkpointer.Reset() // Reset the checkpointer for each PAGE
|
||
ctx = context.WithValues(ctx, "bucket", metadata.bucket, "page_number", metadata.pageNumber)
|
||
for objIdx, obj := range metadata.page.Contents {
|
||
octx := context.WithValues(ctx, "key", *obj.Key, "size", *obj.Size)
|
||
if common.IsDone(octx) {
|
||
return
|
||
}
|
||
|
||
// Skip GLACIER and GLACIER_IR objects.
|
||
if obj.StorageClass == s3types.ObjectStorageClassGlacier || obj.StorageClass == s3types.ObjectStorageClassGlacierIr {
|
||
octx.Logger().V(5).Info("Skipping object in storage class", "storage_class", obj.StorageClass)
|
||
s.metricsCollector.RecordObjectSkipped(metadata.bucket, "storage_class", float64(*obj.Size))
|
||
if err := checkpointer.UpdateObjectCompletion(octx, objIdx, metadata.bucket, metadata.role, metadata.page.Contents); err != nil {
|
||
octx.Logger().Error(err, "could not update progress for glacier object")
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Ignore large files.
|
||
if *obj.Size > s.maxObjectSize {
|
||
octx.Logger().V(5).Info("Skipping large file", "max_object_size", s.maxObjectSize)
|
||
s.metricsCollector.RecordObjectSkipped(metadata.bucket, "size_limit", float64(*obj.Size))
|
||
if err := checkpointer.UpdateObjectCompletion(octx, objIdx, metadata.bucket, metadata.role, metadata.page.Contents); err != nil {
|
||
octx.Logger().Error(err, "could not update progress for large file")
|
||
}
|
||
continue
|
||
}
|
||
|
||
// File empty file.
|
||
if *obj.Size == 0 {
|
||
octx.Logger().V(5).Info("Skipping empty file")
|
||
s.metricsCollector.RecordObjectSkipped(metadata.bucket, "empty_file", 0)
|
||
if err := checkpointer.UpdateObjectCompletion(octx, objIdx, metadata.bucket, metadata.role, metadata.page.Contents); err != nil {
|
||
octx.Logger().Error(err, "could not update progress for empty file")
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Skip incompatible extensions.
|
||
if common.SkipFile(*obj.Key) {
|
||
octx.Logger().V(5).Info("Skipping file with incompatible extension")
|
||
s.metricsCollector.RecordObjectSkipped(metadata.bucket, "incompatible_extension", float64(*obj.Size))
|
||
if err := checkpointer.UpdateObjectCompletion(octx, objIdx, metadata.bucket, metadata.role, metadata.page.Contents); err != nil {
|
||
octx.Logger().Error(err, "could not update progress for incompatible file")
|
||
}
|
||
continue
|
||
}
|
||
|
||
s.jobPool.Go(func() error {
|
||
defer common.RecoverWithExit(octx)
|
||
if common.IsDone(octx) {
|
||
return octx.Err()
|
||
}
|
||
|
||
if strings.HasSuffix(*obj.Key, "/") {
|
||
octx.Logger().V(5).Info("Skipping directory")
|
||
s.metricsCollector.RecordObjectSkipped(metadata.bucket, "directory", float64(*obj.Size))
|
||
return nil
|
||
}
|
||
|
||
path := strings.Split(*obj.Key, "/")
|
||
prefix := strings.Join(path[:len(path)-1], "/")
|
||
|
||
nErr, ok := state.errorCount.Load(prefix)
|
||
if !ok {
|
||
nErr = 0
|
||
}
|
||
if nErr.(int) > 3 {
|
||
octx.Logger().V(2).Info("Skipped due to excessive errors")
|
||
return nil
|
||
}
|
||
// Make sure we use a separate context for the GetObjectWithContext call.
|
||
// This ensures that the timeout is isolated and does not affect any downstream operations. (e.g. HandleFile)
|
||
const getObjectTimeout = 30 * time.Second
|
||
objCtx, cancel := context.WithTimeout(octx, getObjectTimeout)
|
||
defer cancel()
|
||
|
||
res, err := metadata.client.GetObject(objCtx, &s3.GetObjectInput{
|
||
Bucket: &metadata.bucket,
|
||
Key: obj.Key,
|
||
})
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "AccessDenied") {
|
||
octx.Logger().Error(err, "could not get S3 object; access denied")
|
||
s.metricsCollector.RecordObjectSkipped(metadata.bucket, "access_denied", float64(*obj.Size))
|
||
} else {
|
||
octx.Logger().Error(err, "could not get S3 object")
|
||
s.metricsCollector.RecordObjectError(metadata.bucket)
|
||
}
|
||
// According to the documentation for GetObjectWithContext,
|
||
// the response can be non-nil even if there was an error.
|
||
// It's uncertain if the body will be nil in such cases,
|
||
// but we'll close it if it's not.
|
||
if res != nil && res.Body != nil {
|
||
_ = res.Body.Close()
|
||
}
|
||
|
||
nErr, ok := state.errorCount.Load(prefix)
|
||
if !ok {
|
||
nErr = 0
|
||
}
|
||
if nErr.(int) > 3 {
|
||
octx.Logger().V(3).Info("Skipped due to excessive errors")
|
||
return nil
|
||
}
|
||
nErr = nErr.(int) + 1
|
||
state.errorCount.Store(prefix, nErr)
|
||
// too many consecutive errors on this page
|
||
if nErr.(int) > 3 {
|
||
octx.Logger().V(2).Info("Too many consecutive errors, excluding prefix", "prefix", prefix)
|
||
}
|
||
return nil
|
||
}
|
||
defer func() { _ = res.Body.Close() }()
|
||
|
||
email := "Unknown"
|
||
if obj.Owner != nil {
|
||
email = *obj.Owner.DisplayName
|
||
}
|
||
modified := obj.LastModified.String()
|
||
chunkSkel := &sources.Chunk{
|
||
SourceType: s.Type(),
|
||
SourceName: s.name,
|
||
SourceID: s.SourceID(),
|
||
JobID: s.JobID(),
|
||
SourceMetadata: &source_metadatapb.MetaData{
|
||
Data: &source_metadatapb.MetaData_S3{
|
||
S3: &source_metadatapb.S3{
|
||
Bucket: metadata.bucket,
|
||
File: sanitizer.UTF8(*obj.Key),
|
||
Link: sanitizer.UTF8(s.objectLink(metadata.bucket, metadata.client.Options().Region, *obj.Key)),
|
||
Email: sanitizer.UTF8(email),
|
||
Timestamp: sanitizer.UTF8(modified),
|
||
},
|
||
},
|
||
},
|
||
SourceVerify: s.verify,
|
||
}
|
||
|
||
if err := handlers.HandleFile(octx, res.Body, chunkSkel, reporter); err != nil {
|
||
octx.Logger().Error(err, "error handling file")
|
||
s.metricsCollector.RecordObjectError(metadata.bucket)
|
||
return nil
|
||
}
|
||
atomic.AddUint64(state.objectCount, 1)
|
||
octx.Logger().V(5).Info("S3 object scanned.", "object_count", state.objectCount)
|
||
nErr, ok = state.errorCount.Load(prefix)
|
||
if !ok {
|
||
nErr = 0
|
||
}
|
||
if nErr.(int) > 0 {
|
||
state.errorCount.Store(prefix, 0)
|
||
}
|
||
// Update progress after successful processing.
|
||
if err := checkpointer.UpdateObjectCompletion(octx, objIdx, metadata.bucket, metadata.role, metadata.page.Contents); err != nil {
|
||
octx.Logger().Error(err, "could not update progress for scanned object")
|
||
}
|
||
s.metricsCollector.RecordObjectScanned(metadata.bucket, float64(*obj.Size))
|
||
return nil
|
||
})
|
||
}
|
||
_ = s.jobPool.Wait()
|
||
}
|
||
|
||
func (s *Source) validateBucketAccess(ctx context.Context, client *s3.Client, roleArn string, buckets []string) []error {
|
||
shouldHaveAccessToAllBuckets := roleArn == ""
|
||
wasAbleToListAnyBucket := false
|
||
var errs []error
|
||
|
||
for _, bucket := range buckets {
|
||
if common.IsDone(ctx) {
|
||
return append(errs, ctx.Err())
|
||
}
|
||
|
||
regionalClient, err := s.getRegionalClientForBucket(ctx, client, roleArn, bucket)
|
||
if err != nil {
|
||
errs = append(errs, fmt.Errorf("could not get regional client for bucket %q: %w", bucket, err))
|
||
continue
|
||
}
|
||
|
||
_, err = regionalClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket})
|
||
if err == nil {
|
||
wasAbleToListAnyBucket = true
|
||
} else if shouldHaveAccessToAllBuckets {
|
||
errs = append(errs, fmt.Errorf("could not list objects in bucket %q: %w", bucket, err))
|
||
}
|
||
}
|
||
|
||
if !wasAbleToListAnyBucket {
|
||
if roleArn == "" {
|
||
errs = append(errs, errors.New("could not list objects in any bucket"))
|
||
} else {
|
||
errs = append(errs, fmt.Errorf("role %q could not list objects in any bucket", roleArn))
|
||
}
|
||
}
|
||
|
||
return errs
|
||
}
|
||
|
||
// visitRoles iterates over the configured AWS roles and calls the provided function
|
||
// for each role, passing in the default S3 client, the role ARN, and the list of
|
||
// buckets to scan.
|
||
//
|
||
// The provided function parameter typically implements the core scanning logic
|
||
// and must handle context cancellation appropriately.
|
||
//
|
||
// If no roles are configured, it will call the function with an empty role ARN.
|
||
func (s *Source) visitRoles(
|
||
ctx context.Context,
|
||
f func(c context.Context, defaultRegionClient *s3.Client, roleArn string, buckets []string) error,
|
||
) error {
|
||
roles := s.conn.GetRoles()
|
||
if len(roles) == 0 {
|
||
roles = []string{""}
|
||
}
|
||
|
||
for _, role := range roles {
|
||
s.metricsCollector.RecordRoleScanned(role)
|
||
|
||
client, err := s.newClient(ctx, s.defaultRegion(), role)
|
||
if err != nil {
|
||
return fmt.Errorf("could not create s3 client: %w", err)
|
||
}
|
||
|
||
bucketsToScan, err := s.getBucketsToScan(ctx, client)
|
||
if err != nil {
|
||
return fmt.Errorf("role %q could not list any s3 buckets for scanning: %w", role, err)
|
||
}
|
||
|
||
if err := f(ctx, client, role, bucketsToScan); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// objectLink creates a URL for an object. AWS buckets get a
|
||
// virtual-hosted–style URI, which has the format:
|
||
// https://[bucket-name].s3.[region-code].amazonaws.com/[key-name]
|
||
//
|
||
// See https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
|
||
//
|
||
// A custom endpoint gets a path-style link, matching how the client addresses
|
||
// it, so the link resolves the same way the scan did.
|
||
func (s *Source) objectLink(bucket, region, key string) string {
|
||
if s.endpoint == nil {
|
||
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key)
|
||
}
|
||
|
||
link := *s.endpoint
|
||
link.Path = strings.TrimSuffix(link.Path, "/") + "/" + bucket + "/" + key
|
||
return link.String()
|
||
}
|
||
|
||
// Enumerate implements SourceUnitEnumerator interface. This implementation visits
|
||
// each configured role and passes each s3 bucket as a source unit
|
||
func (s *Source) Enumerate(ctx context.Context, reporter sources.UnitReporter) error {
|
||
visitor := func(c context.Context, defaultRegionClient *s3.Client, roleArn string, buckets []string) error {
|
||
for _, bucket := range buckets {
|
||
if common.IsDone(ctx) {
|
||
return ctx.Err()
|
||
}
|
||
|
||
unit := S3SourceUnit{
|
||
Bucket: bucket,
|
||
Role: roleArn,
|
||
}
|
||
|
||
if err := reporter.UnitOk(ctx, unit); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
return s.visitRoles(ctx, visitor)
|
||
}
|
||
|
||
// ChunkUnit implements SourceUnitChunker interface. This implementation scans
|
||
// the given S3 bucket source unit and emits chunks for each object found.
|
||
// It supports sub-unit resumption by utilizing the checkpointer to track progress.
|
||
func (s *Source) ChunkUnit(ctx context.Context, unit sources.SourceUnit, reporter sources.ChunkReporter) error {
|
||
|
||
s3unit, ok := unit.(S3SourceUnit)
|
||
if !ok {
|
||
return fmt.Errorf("expected *S3SourceUnit, got %T", unit)
|
||
}
|
||
// unitID is a combination of bucket name and role ARN
|
||
unitID, _ := s3unit.SourceUnitID()
|
||
defaultClient, err := s.newClient(ctx, s.defaultRegion(), s3unit.Role)
|
||
if err != nil {
|
||
return fmt.Errorf("could not create s3 client for bucket %s and role %s: %w", s3unit.Bucket, s3unit.Role, err)
|
||
}
|
||
|
||
checkpointer := NewCheckpointer(ctx, &s.Progress, true)
|
||
|
||
var startAfterPtr *string
|
||
startAfter := s.GetEncodedResumeInfoFor(unitID)
|
||
if startAfter != "" {
|
||
ctx.Logger().V(3).Info(
|
||
"Resuming unit scan",
|
||
"start_after", startAfter,
|
||
"unitID", unitID,
|
||
)
|
||
startAfterPtr = &startAfter
|
||
}
|
||
defer s.ClearEncodedResumeInfoFor(unitID)
|
||
s.scanBucket(ctx, defaultClient, s3unit.Role, s3unit.Bucket, reporter, startAfterPtr, checkpointer)
|
||
return nil
|
||
}
|
||
|
||
// It accepts three shapes: the persisted envelope carrying unit_data
|
||
// (round-trips the unit exactly), the envelope without unit_data (rebuilt from id),
|
||
// and a bare S3SourceUnit.
|
||
func (s *Source) UnmarshalSourceUnit(data []byte) (sources.SourceUnit, error) {
|
||
var envelope unitEnvelope
|
||
if err := json.Unmarshal(data, &envelope); err == nil && envelope.Kind == string(SourceUnitKindBucket) {
|
||
if envelope.UnitData != "" {
|
||
if decoded, err := base64.StdEncoding.DecodeString(envelope.UnitData); err == nil {
|
||
var unit S3SourceUnit
|
||
if json.Unmarshal(decoded, &unit) == nil && unit.Bucket != "" {
|
||
return unit, nil
|
||
}
|
||
}
|
||
}
|
||
if envelope.ID != "" {
|
||
if role, bucket := splitS3SourceUnitID(envelope.ID); bucket != "" {
|
||
return S3SourceUnit{Bucket: bucket, Role: role}, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
var unit S3SourceUnit
|
||
if err := json.Unmarshal(data, &unit); err != nil {
|
||
return nil, err
|
||
}
|
||
unitID, kind := unit.SourceUnitID()
|
||
if unitID == "" || kind != SourceUnitKindBucket {
|
||
return nil, fmt.Errorf("not an S3SourceUnit")
|
||
}
|
||
return unit, nil
|
||
}
|