feat(modules): add vaultwarden, authentik and synapse version modules (#295)

This commit is contained in:
Tigah
2026-07-22 12:47:18 -07:00
committed by GitHub
parent 2a88881961
commit 49e41e4d0f
6 changed files with 387 additions and 0 deletions
@@ -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 := `<html><head><title>authentik</title></head><body>
<script data-id="authentik-config">
"use strict";
window.authentik = {
locale: "en",
config: JSON.parse('{}'),
brand: JSON.parse('{}'),
versionFamily: "2025.12",
versionSubdomain: "version-2025-12",
build: "abc123",
api: { base: "/", relBase: "/" },
};
</script>
</body></html>`
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 := `<html><body><h1>Migrating to authentik for SSO</h1>
<p>We recently switched our identity provider to authentik and could not be happier.</p>
</body></html>`
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 := `<html><body><script>window.myApp = { versionFamily: "9.9" };</script></body></html>`
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))
}
})
}
@@ -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))
}
})
}
@@ -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))
}
})
}
@@ -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
@@ -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
@@ -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