Files
sif/internal/modules/vector_db_exposure_test.go
T
Tigah 7788550722 feat(modules): add qdrant, weaviate and chroma exposure modules (#222)
modules/recon/qdrant-api-exposure.yaml flags a Qdrant vector database that
serves its collections without an api key, keyed on the result envelope wrapping
a collections array paired with the ok status, then extracts the first
collection name. Qdrant gates /collections behind the api key, so an answer here
means the catalog is readable.

modules/recon/weaviate-api-exposure.yaml flags a Weaviate vector database that
leaks its host address and version over the meta api, keyed on the url-valued
hostname paired with the version field, then extracts the hostname. Weaviate
drops the modules field when none are enabled, so the match leans on the
hostname, which it always renders as a scheme and host.

modules/recon/chroma-api-exposure.yaml flags a reachable Chroma vector database
by its heartbeat api on both the v1 and v2 paths, keyed on the nanosecond
heartbeat field. The heartbeat stays anonymous by design, so this is rated as a
reachability signal rather than an auth bypass.

internal/modules/vector_db_exposure_test.go drives the three modules end to end
through ExecuteHTTPModule and asserts the leak alongside the near misses a strict
review wants pinned: each service with one keying field missing, a bare hostname,
a generic version response, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 16:54:03 -07:00

144 lines
4.9 KiB
Go

package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runVectorDBModule(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 vectorDBExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestVectorDBExposureModules(t *testing.T) {
const qdrant = "../../modules/recon/qdrant-api-exposure.yaml"
const weaviate = "../../modules/recon/weaviate-api-exposure.yaml"
const chroma = "../../modules/recon/chroma-api-exposure.yaml"
qdrantCollections := `{"result":{"collections":[{"name":"documents"},{"name":"embeddings"}]},` +
`"status":"ok","time":0.000018}`
weaviateMeta := `{"hostname":"http://[::]:8080","modules":{"text2vec-openai":{"version":"v1.0.0"}},` +
`"version":"1.23.7"}`
chromaHeartbeat := `{"nanosecond heartbeat":1718900000000000000}`
t.Run("a qdrant collections api is flagged and named", func(t *testing.T) {
res := runVectorDBModule(t, qdrant, 200, qdrantCollections)
if len(res.Findings) == 0 {
t.Fatal("expected a qdrant finding")
}
if v := vectorDBExtract(res, "qdrant_collection"); v != "documents" {
t.Errorf("qdrant_collection=%q, want documents", v)
}
})
t.Run("a weaviate meta api is flagged with its hostname", func(t *testing.T) {
res := runVectorDBModule(t, weaviate, 200, weaviateMeta)
if len(res.Findings) == 0 {
t.Fatal("expected a weaviate finding")
}
if v := vectorDBExtract(res, "weaviate_hostname"); v != "http://[::]:8080" {
t.Errorf("weaviate_hostname=%q, want http://[::]:8080", v)
}
})
t.Run("a chroma heartbeat api is flagged", func(t *testing.T) {
res := runVectorDBModule(t, chroma, 200, chromaHeartbeat)
if len(res.Findings) == 0 {
t.Fatal("expected a chroma finding")
}
})
t.Run("a qdrant status without a collections result is not flagged", func(t *testing.T) {
body := `{"result":{"points":[{"id":1}]},"status":"ok","time":0.001}`
if res := runVectorDBModule(t, qdrant, 200, body); len(res.Findings) > 0 {
t.Errorf("a points result should not match qdrant, got %d findings", len(res.Findings))
}
})
t.Run("a qdrant collections result without an ok status is not flagged", func(t *testing.T) {
body := `{"result":{"collections":[{"name":"x"}]}}`
if res := runVectorDBModule(t, qdrant, 200, body); len(res.Findings) > 0 {
t.Errorf("a collections result without ok status should not match qdrant, got %d findings", len(res.Findings))
}
})
t.Run("a weaviate meta without a version is not flagged", func(t *testing.T) {
body := `{"hostname":"http://x:8080","modules":{"a":{}}}`
if res := runVectorDBModule(t, weaviate, 200, body); len(res.Findings) > 0 {
t.Errorf("a meta without a version should not match weaviate, got %d findings", len(res.Findings))
}
})
t.Run("a weaviate hostname that is not a url is not flagged", func(t *testing.T) {
body := `{"hostname":"db-internal","version":"1.23.7"}`
if res := runVectorDBModule(t, weaviate, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare hostname should not match weaviate, got %d findings", len(res.Findings))
}
})
t.Run("a chroma 200 without the heartbeat key is not flagged", func(t *testing.T) {
body := `{"heartbeat":1718900000}`
if res := runVectorDBModule(t, chroma, 200, body); len(res.Findings) > 0 {
t.Errorf("a plain heartbeat key should not match chroma, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not a vector db", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{qdrant, weaviate, chroma} {
if res := runVectorDBModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{qdrant, weaviate, chroma} {
if res := runVectorDBModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{qdrant, weaviate, chroma} {
if res := runVectorDBModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}