diff --git a/internal/scan/cloudstorage.go b/internal/scan/cloudstorage.go index e22a600..54c30dd 100644 --- a/internal/scan/cloudstorage.go +++ b/internal/scan/cloudstorage.go @@ -15,6 +15,7 @@ package scan import ( "context" "fmt" + "io" "net/http" "os" "strings" @@ -26,13 +27,33 @@ import ( "github.com/vmfunc/sif/internal/styles" ) +// maxBucketBodyReadBytes bounds how much of a bucket response we buffer to +// look for the listing markers below. an XML/JSON listing root or an error +// code always shows up well within this window; the rest is drained and +// discarded by httpx.DrainClose. +const maxBucketBodyReadBytes = 8 << 10 + +// s3ListingMarker is the root element of a real S3 ListBucketResult XML +// listing. its presence is what actually proves anonymous listing access; +// AccessDenied/NoSuchBucket/redirect bodies also come back with status 200 +// and must not be mistaken for a listing. +const s3ListingMarker = " +AccessDeniedAccess Denied`, + want: false, + }, + { + name: "200 with unrelated landing page is not a listing", + statusCode: http.StatusOK, + body: `

Welcome to example corp

`, + want: false, + }, + { + name: "200 with empty body is not a listing", + statusCode: http.StatusOK, + body: ``, + want: false, + }, + { + name: "403 AccessDenied is not a listing", + statusCode: http.StatusForbidden, + body: ` +AccessDeniedAccess Denied`, + want: false, + }, + { + name: "200 with a real ListBucketResult is a listing", + statusCode: http.StatusOK, + body: ` + +example-bucketsecret.txt +`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + orig := s3EndpointFmt + s3EndpointFmt = srv.URL + "/%s" + defer func() { s3EndpointFmt = orig }() + + client := httpx.Client(5 * time.Second) + got, err := checkS3Bucket(context.Background(), "some-bucket", client) + if err != nil { + t.Fatalf("checkS3Bucket() error = %v", err) + } + if got != tt.want { + t.Errorf("checkS3Bucket() = %v, want %v (status=%d body=%q)", got, tt.want, tt.statusCode, tt.body) + } + }) + } +} + +func TestIsListableBucketBody(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "real listing", + body: `b`, + want: true, + }, + { + name: "access denied even if listing marker somehow present", + body: `bAccessDenied`, + want: false, + }, + {name: "no marker at all", body: `nothing here`, want: false}, + {name: "empty body", body: ``, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isListableBucketBody([]byte(tt.body)); got != tt.want { + t.Errorf("isListableBucketBody(%q) = %v, want %v", tt.body, got, tt.want) + } + }) + } +} diff --git a/internal/scan/integration_test.go b/internal/scan/integration_test.go index 1b65585..84f0a79 100644 --- a/internal/scan/integration_test.go +++ b/internal/scan/integration_test.go @@ -371,11 +371,14 @@ func TestIntegrationSecurityTrails(t *testing.T) { } func TestIntegrationCloudStorage(t *testing.T) { - // the fixture returns 200 only for the planted bucket, so any candidate that - // matches it is reported public. + // the fixture serves a real ListBucketResult listing only for the planted + // bucket, so any candidate that matches it is reported public; a bare 200 + // with no listing body is (correctly) not enough to flag one. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/example" { w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(` +examplef`)) return } w.WriteHeader(http.StatusNotFound)