Files
trufflehog/pkg/sources/huggingface/bucket.go
Julien Chaumond 4945fa3cd2 huggingface: add bucket scanning (#5017)
* Fix incorrect doc comments in huggingface engine

* add huggingface bucket scanning

* Update client.go

* use limit=5000 on bucket listing endpoints

* regenerate man page

* escape bucket file path per-segment instead of whole

* escape file path in bucket finding link too

* use separate client without overall timeout for bucket file downloads
2026-06-24 10:40:58 -04:00

151 lines
5.2 KiB
Go

package huggingface
import (
"fmt"
"strings"
"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/pb/source_metadatapb"
"github.com/trufflesecurity/trufflehog/v3/pkg/sanitizer"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
)
// maxBucketFileSize is the largest bucket file that will be downloaded and
// scanned. Files larger than this are skipped.
const maxBucketFileSize = 250 * 1024 * 1024 // 250MB
// cacheBucketInfo fetches bucket metadata (notably its visibility) and caches
// it, then adds the bucket to the list of buckets to scan. Unlike models,
// spaces, and datasets, buckets are not git repos, so they are keyed by their
// plain "namespace/name" ID instead of a clone URL.
func (s *Source) cacheBucketInfo(ctx context.Context, bucketID string) error {
bucketCtx := context.WithValue(ctx, BUCKET, bucketID)
if _, ok := s.repoInfoCache.get(bucketID); !ok {
bucketCtx.Logger().V(2).Info("Caching bucket info")
bucket, err := s.apiClient.GetBucket(bucketCtx, bucketID)
if err != nil {
bucketCtx.Logger().Error(err, "failed to fetch bucket")
return err
}
if bucket.BucketID == "" {
bucketCtx.Logger().Error(fmt.Errorf("no bucket found for bucket"), bucketID)
return nil
}
s.repoInfoCache.put(bucketID, repoInfo{
owner: strings.Split(bucket.BucketID, "/")[0],
name: strings.Split(bucket.BucketID, "/")[1],
fullName: bucket.BucketID,
visibility: getVisibility(bucket.IsPrivate),
resourceType: BUCKET,
})
}
s.buckets = append(s.buckets, bucketID)
return nil
}
// fetchAndCacheBuckets enumerates all buckets belonging to an author (user or
// org) and caches the ones that survive include/exclude filtering.
func (s *Source) fetchAndCacheBuckets(ctx context.Context, author string) error {
buckets, err := s.apiClient.ListBucketsByAuthor(ctx, author)
if err != nil {
return err
}
for _, bucket := range buckets {
s.filteredBucketsCache.Set(bucket.BucketID, bucket.BucketID)
// Set is a no-op for buckets excluded by include/ignore globs.
if _, ok := s.filteredBucketsCache.Get(bucket.BucketID); !ok {
continue
}
if err := s.cacheBucketInfo(ctx, bucket.BucketID); err != nil {
continue
}
}
return nil
}
// scanBuckets lists and scans the files of all enumerated buckets. Buckets
// are object storage rather than git repos, so files are downloaded
// individually and chunked directly.
func (s *Source) scanBuckets(ctx context.Context, chunksChan chan *sources.Chunk) error {
ctx.Logger().V(2).Info("Found buckets to scan", "count", len(s.buckets))
scanErrs := sources.NewScanErrors()
reporter := sources.ChanReporter{Ch: chunksChan}
for _, bucketID := range s.buckets {
bucketInfo, ok := s.repoInfoCache.get(bucketID)
if !ok {
// This should never happen.
err := fmt.Errorf("no bucketInfo for bucket: %s", bucketID)
ctx.Logger().Error(err, "failed to scan bucket")
continue
}
bucketCtx := context.WithValues(ctx, BUCKET, bucketID)
files, err := s.apiClient.ListBucketFiles(bucketCtx, bucketID)
if err != nil {
scanErrs.Add(fmt.Errorf("error listing files in bucket %s: %w", bucketID, err))
continue
}
for _, file := range files {
s.jobPool.Go(func() error {
if common.IsDone(bucketCtx) {
return nil
}
if file.Type != "file" {
return nil
}
if file.Size > maxBucketFileSize {
bucketCtx.Logger().V(2).Info("Skipping bucket file: exceeds max size", "path", file.Path, "size", file.Size)
return nil
}
if err := s.scanBucketFile(bucketCtx, bucketID, bucketInfo, file, reporter); err != nil {
scanErrs.Add(fmt.Errorf("error scanning file %s in bucket %s: %w", file.Path, bucketID, err))
}
return nil
})
}
}
_ = s.jobPool.Wait()
if scanErrs.Count() > 0 {
ctx.Logger().V(0).Info("failed to scan some buckets", "error_count", scanErrs.Count(), "errors", scanErrs.String())
}
s.SetProgressComplete(len(s.buckets), len(s.buckets), "Completed HuggingFace bucket scan", "")
return nil
}
// scanBucketFile downloads a single bucket file and chunks its content.
func (s *Source) scanBucketFile(ctx context.Context, bucketID string, bucketInfo repoInfo, file BucketFile, reporter sources.ChanReporter) error {
body, err := s.apiClient.DownloadBucketFile(ctx, bucketID, file.Path)
if err != nil {
return err
}
defer func() { _ = body.Close() }()
chunkSkel := &sources.Chunk{
SourceType: s.Type(),
SourceName: s.name,
SourceID: s.SourceID(),
JobID: s.JobID(),
SourceMetadata: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Huggingface{
Huggingface: &source_metadatapb.Huggingface{
File: sanitizer.UTF8(file.Path),
Link: sanitizer.UTF8(fmt.Sprintf("%s/%s/%s/resolve/%s", s.conn.Endpoint, BucketsRoute, bucketID, escapePathSegments(file.Path))),
Repository: sanitizer.UTF8(fmt.Sprintf("%s/%s/%s", s.conn.Endpoint, BucketsRoute, bucketID)),
Visibility: bucketInfo.visibility,
ResourceType: BUCKET,
},
},
},
SourceVerify: s.verify,
}
return handlers.HandleFile(ctx, body, chunkSkel, reporter)
}