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