mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
feat(modules): add s3, gcs and azure bucket listing modules (#288)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user