feat(modules): add s3, gcs and azure bucket listing modules (#288)

This commit is contained in:
Tigah
2026-07-22 12:45:57 -07:00
committed by GitHub
parent 9be3d4a51c
commit 1ea8eff267
4 changed files with 353 additions and 0 deletions
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>` +
`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">` +
`<Name>leaky-prod-backups</Name><Prefix></Prefix><Marker></Marker>` +
`<MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated>` +
`<Contents><Key>db-dump-2026-06-01.sql.gz</Key><LastModified>2026-06-01T00:00:00.000Z</LastModified>` +
`<ETag>"abc"</ETag><Size>10485760</Size><StorageClass>STANDARD</StorageClass></Contents>` +
`</ListBucketResult>`
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 := `<?xml version="1.0" encoding="UTF-8"?>` +
`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">` +
`<Name>empty-public-bucket</Name><Prefix></Prefix><Marker></Marker>` +
`<MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated></ListBucketResult>`
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 := `<?xml version="1.0" encoding="UTF-8"?>` +
`<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>`
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 := `<?xml version="1.0" encoding="UTF-8"?>` +
`<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>`
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 := `<?xml version="1.0" encoding="UTF-8"?>` +
`<Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message></Error>`
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 := "<!DOCTYPE html><html><head><title>hi</title></head><body>hello</body></html>"
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 := "<!DOCTYPE html><html><head><title>hi</title></head><body>hello</body></html>"
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 := `<?xml version="1.0" encoding="utf-8"?>` +
`<EnumerationResults ServiceEndpoint="https://leakyacct.blob.core.windows.net/" ContainerName="public">` +
`<Blobs><Blob><Name>invoice-2026-06.pdf</Name><Properties><Content-Length>2048</Content-Length></Properties></Blob></Blobs>` +
`<NextMarker/></EnumerationResults>`
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 := `<?xml version="1.0" encoding="utf-8"?>` +
`<EnumerationResults ServiceEndpoint="https://leakyacct.blob.core.windows.net/" ContainerName="empty">` +
`<Blobs/><NextMarker/></EnumerationResults>`
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 := `<?xml version="1.0" encoding="utf-8"?>` +
`<Error><Code>PublicAccessNotPermitted</Code>` +
`<Message>Public access is not permitted on this storage account.</Message></Error>`
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 := `<?xml version="1.0" encoding="utf-8"?>` +
`<Error><Code>ContainerNotFound</Code><Message>The specified container does not exist.</Message></Error>`
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 := "<!DOCTYPE html><html><head><title>hi</title></head><body>hello</body></html>"
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))
}
})
}
@@ -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:
- '<EnumerationResults[^>]*>'
- type: word
part: body
negative: true
condition: or
words:
- "PublicAccessNotPermitted"
- "ResourceNotFound"
- "ContainerNotFound"
- "AuthenticationFailed"
- "<Error>"
extractors:
- type: regex
name: blob_name
part: body
regex:
- '<Name>([^<]+)</Name>'
group: 1
@@ -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
@@ -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:
- '<ListBucketResult[^>]*xmlns="http://s3\.amazonaws\.com/doc/2006-03-01/"'
- type: word
part: body
negative: true
condition: or
words:
- "<Error>"
- "AccessDenied"
- "NoSuchBucket"
- "AllAccessDisabled"
extractors:
- type: regex
name: bucket_name
part: body
regex:
- '<Name>([^<]+)</Name>'
group: 1
- type: regex
name: object_key
part: body
regex:
- '<Key>([^<]+)</Key>'
group: 1