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