From 1ea8eff267928a082aecef18f4747a2d90a723a7 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:45:57 -0700 Subject: [PATCH] feat(modules): add s3, gcs and azure bucket listing modules (#288) --- .../modules/bucket_listing_exposure_test.go | 205 ++++++++++++++++++ ...azure-blob-container-listing-exposure.yaml | 47 ++++ .../recon/gcs-bucket-listing-exposure.yaml | 47 ++++ modules/recon/s3-bucket-listing-exposure.yaml | 54 +++++ 4 files changed, 353 insertions(+) create mode 100644 internal/modules/bucket_listing_exposure_test.go create mode 100644 modules/recon/azure-blob-container-listing-exposure.yaml create mode 100644 modules/recon/gcs-bucket-listing-exposure.yaml create mode 100644 modules/recon/s3-bucket-listing-exposure.yaml diff --git a/internal/modules/bucket_listing_exposure_test.go b/internal/modules/bucket_listing_exposure_test.go new file mode 100644 index 0000000..e10c4cd --- /dev/null +++ b/internal/modules/bucket_listing_exposure_test.go @@ -0,0 +1,205 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runBucketModule(t *testing.T, file string, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule(file) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{ + Timeout: 5 * time.Second, + Threads: 2, + }) + if err != nil { + t.Fatalf("execute %s: %v", file, err) + } + return res +} + +func bucketExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestS3BucketListingExposureModule(t *testing.T) { + const s3 = "../../modules/recon/s3-bucket-listing-exposure.yaml" + + t.Run("a real public bucket listing is flagged with the bucket and key", func(t *testing.T) { + body := `` + + `` + + `leaky-prod-backups` + + `1000false` + + `db-dump-2026-06-01.sql.gz2026-06-01T00:00:00.000Z` + + `"abc"10485760STANDARD` + + `` + res := runBucketModule(t, s3, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an s3 bucket listing finding") + } + if v := bucketExtract(res, "bucket_name"); v != "leaky-prod-backups" { + t.Errorf("bucket_name=%q, want leaky-prod-backups", v) + } + if v := bucketExtract(res, "object_key"); v != "db-dump-2026-06-01.sql.gz" { + t.Errorf("object_key=%q, want db-dump-2026-06-01.sql.gz", v) + } + }) + + t.Run("an empty but public bucket still fires without needing contents", func(t *testing.T) { + body := `` + + `` + + `empty-public-bucket` + + `1000false` + res := runBucketModule(t, s3, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an s3 bucket listing finding on an empty public bucket") + } + }) + + t.Run("a locked bucket returning AccessDenied with 200 does not fire", func(t *testing.T) { + body := `` + + `AccessDeniedAccess Denied` + if res := runBucketModule(t, s3, 200, body); len(res.Findings) > 0 { + t.Errorf("a 200 AccessDenied body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a locked bucket returning 403 AccessDenied does not fire", func(t *testing.T) { + body := `` + + `AccessDeniedAccess Denied` + if res := runBucketModule(t, s3, 403, body); len(res.Findings) > 0 { + t.Errorf("a 403 AccessDenied body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a gone bucket returning NoSuchBucket does not fire", func(t *testing.T) { + body := `` + + `NoSuchBucketThe specified bucket does not exist` + if res := runBucketModule(t, s3, 404, body); len(res.Findings) > 0 { + t.Errorf("a NoSuchBucket body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a normal html page does not fire", func(t *testing.T) { + body := "hihello" + if res := runBucketModule(t, s3, 200, body); len(res.Findings) > 0 { + t.Errorf("a plain html page should not match, got %d findings", len(res.Findings)) + } + }) +} + +func TestGCSBucketListingExposureModule(t *testing.T) { + const gcs = "../../modules/recon/gcs-bucket-listing-exposure.yaml" + + t.Run("a real public gcs listing is flagged with the object name", func(t *testing.T) { + body := `{"kind": "storage#objects", "items": [{"kind": "storage#object", "id": "leaky-gcs-bucket/report.csv/1",` + + `"name": "report.csv", "bucket": "leaky-gcs-bucket", "size": "4096"}]}` + res := runBucketModule(t, gcs, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a gcs bucket listing finding") + } + if v := bucketExtract(res, "object_name"); v != "report.csv" { + t.Errorf("object_name=%q, want report.csv", v) + } + }) + + t.Run("an empty but public bucket still fires without needing items", func(t *testing.T) { + body := `{"kind": "storage#objects"}` + res := runBucketModule(t, gcs, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a gcs bucket listing finding on an empty public bucket") + } + }) + + t.Run("a locked bucket returning a forbidden error does not fire", func(t *testing.T) { + body := `{"error": {"errors": [{"domain": "global", "reason": "forbidden", "message": "does not have storage.objects.list access"}], "code": 403, "message": "forbidden"}}` + if res := runBucketModule(t, gcs, 403, body); len(res.Findings) > 0 { + t.Errorf("a forbidden error body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a missing bucket returning notFound does not fire", func(t *testing.T) { + body := `{"error": {"errors": [{"domain": "global", "reason": "notFound", "message": "not found"}], "code": 404, "message": "not found"}}` + if res := runBucketModule(t, gcs, 404, body); len(res.Findings) > 0 { + t.Errorf("a notFound error body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a normal html page does not fire", func(t *testing.T) { + body := "hihello" + if res := runBucketModule(t, gcs, 200, body); len(res.Findings) > 0 { + t.Errorf("a plain html page should not match, got %d findings", len(res.Findings)) + } + }) +} + +func TestAzureBlobContainerListingExposureModule(t *testing.T) { + const azure = "../../modules/recon/azure-blob-container-listing-exposure.yaml" + + t.Run("a real public container listing is flagged with the blob name", func(t *testing.T) { + body := `` + + `` + + `invoice-2026-06.pdf2048` + + `` + res := runBucketModule(t, azure, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an azure container listing finding") + } + if v := bucketExtract(res, "blob_name"); v != "invoice-2026-06.pdf" { + t.Errorf("blob_name=%q, want invoice-2026-06.pdf", v) + } + }) + + t.Run("an empty but public container still fires without needing blobs", func(t *testing.T) { + body := `` + + `` + + `` + res := runBucketModule(t, azure, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an azure container listing finding on an empty public container") + } + }) + + t.Run("a locked container returning PublicAccessNotPermitted does not fire", func(t *testing.T) { + body := `` + + `PublicAccessNotPermitted` + + `Public access is not permitted on this storage account.` + if res := runBucketModule(t, azure, 404, body); len(res.Findings) > 0 { + t.Errorf("a PublicAccessNotPermitted body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a missing container returning ContainerNotFound does not fire", func(t *testing.T) { + body := `` + + `ContainerNotFoundThe specified container does not exist.` + if res := runBucketModule(t, azure, 404, body); len(res.Findings) > 0 { + t.Errorf("a ContainerNotFound body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a normal html page does not fire", func(t *testing.T) { + body := "hihello" + if res := runBucketModule(t, azure, 200, body); len(res.Findings) > 0 { + t.Errorf("a plain html page should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/recon/azure-blob-container-listing-exposure.yaml b/modules/recon/azure-blob-container-listing-exposure.yaml new file mode 100644 index 0000000..7e2e66b --- /dev/null +++ b/modules/recon/azure-blob-container-listing-exposure.yaml @@ -0,0 +1,47 @@ +# Azure Blob Container Listing Exposure Detection Module + +id: azure-blob-container-listing-exposure +info: + name: Azure Blob Container Listing Exposure + author: sif + severity: high + description: Detects an anonymously listable azure blob storage container that returns a full blob enumeration without authentication + tags: [azure, blob, container, storage, cloud, exposure, unauth, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/?restype=container&comp=list" + + threads: 3 + + matchers: + - type: status + status: + - 200 + + - type: regex + part: body + regex: + - ']*>' + + - type: word + part: body + negative: true + condition: or + words: + - "PublicAccessNotPermitted" + - "ResourceNotFound" + - "ContainerNotFound" + - "AuthenticationFailed" + - "" + + extractors: + - type: regex + name: blob_name + part: body + regex: + - '([^<]+)' + group: 1 diff --git a/modules/recon/gcs-bucket-listing-exposure.yaml b/modules/recon/gcs-bucket-listing-exposure.yaml new file mode 100644 index 0000000..230b6e7 --- /dev/null +++ b/modules/recon/gcs-bucket-listing-exposure.yaml @@ -0,0 +1,47 @@ +# Google Cloud Storage Bucket Listing Exposure Detection Module + +id: gcs-bucket-listing-exposure +info: + name: Google Cloud Storage Bucket Listing Exposure + author: sif + severity: high + description: Detects an anonymously listable google cloud storage bucket that returns a json object listing without authentication + tags: [gcs, google, bucket, storage, cloud, exposure, unauth, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/o" + - "{{BaseURL}}/o?alt=json" + + threads: 3 + + matchers: + - type: status + status: + - 200 + + - type: regex + part: body + regex: + - '"kind"\s*:\s*"storage#objects"' + + - type: word + part: body + negative: true + condition: or + words: + - '"reason": "forbidden"' + - '"reason": "required"' + - '"reason": "notFound"' + - '"error":' + + extractors: + - type: regex + name: object_name + part: body + regex: + - '"name"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/s3-bucket-listing-exposure.yaml b/modules/recon/s3-bucket-listing-exposure.yaml new file mode 100644 index 0000000..29bf760 --- /dev/null +++ b/modules/recon/s3-bucket-listing-exposure.yaml @@ -0,0 +1,54 @@ +# S3-Compatible Bucket Listing Exposure Detection Module + +id: s3-bucket-listing-exposure +info: + name: S3-Compatible Bucket Listing Exposure + author: sif + severity: high + description: Detects an anonymously listable S3-compatible object store (aws s3, digitalocean spaces, minio and other s3-api-compatible backends) that returns a full object listing without authentication + tags: [s3, spaces, minio, bucket, storage, cloud, exposure, unauth, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/" + - "{{BaseURL}}/?list-type=2" + + threads: 3 + + matchers: + - type: status + status: + - 200 + + - type: regex + part: body + regex: + - ']*xmlns="http://s3\.amazonaws\.com/doc/2006-03-01/"' + + - type: word + part: body + negative: true + condition: or + words: + - "" + - "AccessDenied" + - "NoSuchBucket" + - "AllAccessDisabled" + + extractors: + - type: regex + name: bucket_name + part: body + regex: + - '([^<]+)' + group: 1 + + - type: regex + name: object_key + part: body + regex: + - '([^<]+)' + group: 1