From 49e41e4d0f1a0dc18a75c5e806bf12c1ea198a17 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:47:18 -0700 Subject: [PATCH] feat(modules): add vaultwarden, authentik and synapse version modules (#295) --- .../authentik_version_exposure_test.go | 93 +++++++++++++++++++ ...ynapse_federation_version_exposure_test.go | 93 +++++++++++++++++++ .../vaultwarden_version_exposure_test.go | 84 +++++++++++++++++ modules/recon/authentik-version-exposure.yaml | 39 ++++++++ .../synapse-federation-version-exposure.yaml | 39 ++++++++ .../recon/vaultwarden-version-exposure.yaml | 39 ++++++++ 6 files changed, 387 insertions(+) create mode 100644 internal/modules/authentik_version_exposure_test.go create mode 100644 internal/modules/synapse_federation_version_exposure_test.go create mode 100644 internal/modules/vaultwarden_version_exposure_test.go create mode 100644 modules/recon/authentik-version-exposure.yaml create mode 100644 modules/recon/synapse-federation-version-exposure.yaml create mode 100644 modules/recon/vaultwarden-version-exposure.yaml diff --git a/internal/modules/authentik_version_exposure_test.go b/internal/modules/authentik_version_exposure_test.go new file mode 100644 index 0000000..80e91ea --- /dev/null +++ b/internal/modules/authentik_version_exposure_test.go @@ -0,0 +1,93 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runAuthentikModule(t *testing.T, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule("../../modules/recon/authentik-version-exposure.yaml") + if err != nil { + t.Fatalf("parse authentik module: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *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 authentik module: %v", err) + } + return res +} + +func authentikExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestAuthentikVersionExposureModule(t *testing.T) { + // shape taken from authentik/core/templates/base/header_js.html, rendered + // unauthenticated on the default authentication flow page + authentikBody := `authentik + + ` + + t.Run("an exposed authentik login flow page is flagged and versioned", func(t *testing.T) { + res := runAuthentikModule(t, 200, authentikBody) + if len(res.Findings) == 0 { + t.Fatal("expected an authentik finding") + } + if v := authentikExtract(res, "authentik_version_family"); v != "2025.12" { + t.Errorf("authentik_version_family=%q, want 2025.12", v) + } + }) + + t.Run("a blog post mentioning authentik is not flagged", func(t *testing.T) { + body := `

Migrating to authentik for SSO

+

We recently switched our identity provider to authentik and could not be happier.

+ ` + if res := runAuthentikModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("prose mentioning authentik should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a generic sso login page with a version field is not authentik", func(t *testing.T) { + body := `` + if res := runAuthentikModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("a generic sso page should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runAuthentikModule(t, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/internal/modules/synapse_federation_version_exposure_test.go b/internal/modules/synapse_federation_version_exposure_test.go new file mode 100644 index 0000000..5a4f46e --- /dev/null +++ b/internal/modules/synapse_federation_version_exposure_test.go @@ -0,0 +1,93 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runSynapseModule(t *testing.T, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule("../../modules/recon/synapse-federation-version-exposure.yaml") + if err != nil { + t.Fatalf("parse synapse module: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *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 synapse module: %v", err) + } + return res +} + +func synapseExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestSynapseFederationVersionExposureModule(t *testing.T) { + // shape from element-hq/synapse FederationVersionServlet.on_GET, the + // matrix server-server federation version endpoint (unauthenticated by spec) + synapseBody := `{"server": {"name": "Synapse", "version": "1.99.0"}}` + + t.Run("an exposed synapse federation version endpoint is flagged and versioned", func(t *testing.T) { + res := runSynapseModule(t, 200, synapseBody) + if len(res.Findings) == 0 { + t.Fatal("expected a synapse finding") + } + if v := synapseExtract(res, "synapse_version"); v != "1.99.0" { + t.Errorf("synapse_version=%q, want 1.99.0", v) + } + }) + + t.Run("a dendrite homeserver on the same federation endpoint is not flagged as synapse", func(t *testing.T) { + // dendrite implements the same open matrix federation version api + // with the same json shape but its own implementation name + body := `{"server": {"name": "Dendrite", "version": "0.13.7"}}` + if res := runSynapseModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("a dendrite homeserver should not match synapse, got %d findings", len(res.Findings)) + } + }) + + t.Run("a conduit homeserver on the same federation endpoint is not flagged as synapse", func(t *testing.T) { + body := `{"server": {"name": "Conduit", "version": "0.9.0"}}` + if res := runSynapseModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("a conduit homeserver should not match synapse, got %d findings", len(res.Findings)) + } + }) + + t.Run("version still extracts when json keys are reordered", func(t *testing.T) { + // json object member order is not significant; the extractor must not + // depend on name preceding version + body := `{"server": {"version": "1.99.0", "name": "Synapse"}}` + res := runSynapseModule(t, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a synapse finding on reordered keys") + } + if v := synapseExtract(res, "synapse_version"); v != "1.99.0" { + t.Errorf("synapse_version=%q on reordered keys, want 1.99.0", v) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runSynapseModule(t, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/internal/modules/vaultwarden_version_exposure_test.go b/internal/modules/vaultwarden_version_exposure_test.go new file mode 100644 index 0000000..6ae70f7 --- /dev/null +++ b/internal/modules/vaultwarden_version_exposure_test.go @@ -0,0 +1,84 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runVaultwardenModule(t *testing.T, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule("../../modules/recon/vaultwarden-version-exposure.yaml") + if err != nil { + t.Fatalf("parse vaultwarden module: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *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 vaultwarden module: %v", err) + } + return res +} + +func vaultwardenExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestVaultwardenVersionExposureModule(t *testing.T) { + // shape taken directly from dani-garcia/vaultwarden src/api/core/mod.rs fn config() + vaultwardenBody := `{"version":"2025.12.0","gitHash":"a1b2c3d",` + + `"server":{"name":"Vaultwarden","url":"https://github.com/dani-garcia/vaultwarden"},` + + `"settings":{"disableUserRegistration":false},` + + `"environment":{"vault":"https://vault.example.com"}}` + + t.Run("an exposed vaultwarden config endpoint is flagged and versioned", func(t *testing.T) { + res := runVaultwardenModule(t, 200, vaultwardenBody) + if len(res.Findings) == 0 { + t.Fatal("expected a vaultwarden finding") + } + if v := vaultwardenExtract(res, "vaultwarden_version"); v != "2025.12.0" { + t.Errorf("vaultwarden_version=%q, want 2025.12.0", v) + } + }) + + t.Run("an official bitwarden server config is not flagged as vaultwarden", func(t *testing.T) { + // the official bitwarden server config endpoint shares the same json + // shape (clients are built against both) but reports its own server name + body := `{"version":"2025.12.0","gitHash":"deadbee",` + + `"server":{"name":"Bitwarden","url":"https://github.com/bitwarden/server"},` + + `"settings":{"disableUserRegistration":true}}` + if res := runVaultwardenModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("an official bitwarden config should not match vaultwarden, got %d findings", len(res.Findings)) + } + }) + + t.Run("a generic json with a version field is not vaultwarden", func(t *testing.T) { + body := `{"version":"1.0.0","server":{"name":"SomeOtherApp"}}` + if res := runVaultwardenModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("a generic json should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runVaultwardenModule(t, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/recon/authentik-version-exposure.yaml b/modules/recon/authentik-version-exposure.yaml new file mode 100644 index 0000000..61f9113 --- /dev/null +++ b/modules/recon/authentik-version-exposure.yaml @@ -0,0 +1,39 @@ +# Authentik Version Endpoint Exposure Detection Module + +id: authentik-version-exposure +info: + name: Authentik Version Exposure + author: sif + severity: info + description: Detects an Authentik identity provider that discloses its version family over the public default authentication flow page, rendered before any credentials are submitted + tags: [authentik, sso, idp, identity, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/if/flow/default-authentication-flow/" + + matchers: + - type: word + part: body + words: + - "window.authentik" + + - type: word + part: body + words: + - "versionFamily" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: authentik_version_family + part: body + regex: + - 'versionFamily:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/synapse-federation-version-exposure.yaml b/modules/recon/synapse-federation-version-exposure.yaml new file mode 100644 index 0000000..3500a7b --- /dev/null +++ b/modules/recon/synapse-federation-version-exposure.yaml @@ -0,0 +1,39 @@ +# Synapse Matrix Homeserver Federation Version Exposure Detection Module + +id: synapse-federation-version-exposure +info: + name: Synapse Matrix Homeserver Federation Version Exposure + author: sif + severity: info + description: Detects a Synapse matrix homeserver that discloses its implementation name and version over the federation version endpoint; this route is unauthenticated by the matrix server-server spec so any homeserver can identify a remote peer before federating with it + tags: [synapse, matrix, homeserver, federation, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/_matrix/federation/v1/version" + + matchers: + - type: regex + part: body + regex: + - '"name"\s*:\s*"Synapse"' + + - type: word + part: body + words: + - "\"version\"" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: synapse_version + part: body + regex: + - '"version"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/vaultwarden-version-exposure.yaml b/modules/recon/vaultwarden-version-exposure.yaml new file mode 100644 index 0000000..bf168ab --- /dev/null +++ b/modules/recon/vaultwarden-version-exposure.yaml @@ -0,0 +1,39 @@ +# Vaultwarden Version Endpoint Exposure Detection Module + +id: vaultwarden-version-exposure +info: + name: Vaultwarden Version Exposure + author: sif + severity: info + description: Detects a Vaultwarden (unofficial bitwarden-compatible) server that discloses its identity and client-compat version over the pre-login config endpoint; this route is served with no auth guard by design so bitwarden clients can read it before a user logs in + tags: [vaultwarden, bitwarden, password-manager, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/api/config" + + matchers: + - type: regex + part: body + regex: + - '"server"\s*:\s*\{\s*"name"\s*:\s*"Vaultwarden"' + + - type: word + part: body + words: + - "\"version\"" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: vaultwarden_version + part: body + regex: + - '"version"\s*:\s*"([^"]+)"' + group: 1