diff --git a/internal/modules/mailadmin_fingerprint_test.go b/internal/modules/mailadmin_fingerprint_test.go new file mode 100644 index 0000000..283a4bb --- /dev/null +++ b/internal/modules/mailadmin_fingerprint_test.go @@ -0,0 +1,175 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runMailAdminModule(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 +} + +// real body fragments below are trimmed from postfixadmin/postfixadmin master and +// postfixadmin-3.2 public/setup.php and public/login.php + templates/index.tpl, +// fetched from raw.githubusercontent.com to anchor the markers on actual product output. + +const postfixadminSetupUnconfigured = `Postfix Admin - Setup +

Configure and Setup Postfixadmin

+

This page helps you setup PostfixAdmin. For further help see the documentation.

+
  • You need to have a setup_password hash configured in a config.local.php file
  • +
    + + + +
    ` + +const postfixadminSetupConfigured = `Postfix Admin - Setup +

    Configure and Setup Postfixadmin

    +
  • setup_password configured
  • +

    Login with setup_password

    +
    + + + +
    ` + +const postfixadminSetupOld32 = ` +

    Postfix Admin Setup Checker

    +

    Running software:

    +
    + + + +
    + +` + +const postfixadminLoginPage = ` +Postfix Admin - mail.example.com + + +
    +

    Login

    +
    +` + +func TestPostfixAdminSetupExposureModule(t *testing.T) { + const setup = "../../modules/recon/postfixadmin-setup-exposure.yaml" + + t.Run("an unconfigured setup page is flagged", func(t *testing.T) { + res := runMailAdminModule(t, setup, 200, postfixadminSetupUnconfigured) + if len(res.Findings) == 0 { + t.Fatal("expected a finding on an unconfigured postfixadmin setup page") + } + }) + + t.Run("a configured setup login page is flagged", func(t *testing.T) { + res := runMailAdminModule(t, setup, 200, postfixadminSetupConfigured) + if len(res.Findings) == 0 { + t.Fatal("expected a finding on a configured postfixadmin setup page") + } + }) + + t.Run("an old-branch (3.2 style) setup checker page is flagged", func(t *testing.T) { + res := runMailAdminModule(t, setup, 200, postfixadminSetupOld32) + if len(res.Findings) == 0 { + t.Fatal("expected a finding on the older postfixadmin setup checker layout") + } + }) + + t.Run("the postfixadmin login page (no setup_password field) is not flagged", func(t *testing.T) { + if res := runMailAdminModule(t, setup, 200, postfixadminLoginPage); len(res.Findings) > 0 { + t.Errorf("the login page should not match the setup module, got %d findings", len(res.Findings)) + } + }) + + t.Run("a generic admin login page is not flagged", func(t *testing.T) { + body := `
    +
    ` + if res := runMailAdminModule(t, setup, 200, body); len(res.Findings) > 0 { + t.Errorf("a generic admin login should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("prose mentioning postfixadmin without the setup form is not flagged", func(t *testing.T) { + body := `

    Postfix Admin review

    +

    Postfix Admin is a popular web based interface used to manage mailboxes, virtual +domains and aliases for a Postfix mail server. Many admins prefer it over other panels +and it integrates well with a setup_password (their term for the install-time secret) +described elsewhere in the docs.

    ` + if res := runMailAdminModule(t, setup, 200, body); len(res.Findings) > 0 { + t.Errorf("a prose page discussing setup_password should not match without the real form, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not flagged", func(t *testing.T) { + if res := runMailAdminModule(t, setup, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} + +func TestPostfixAdminPanelModule(t *testing.T) { + const panel = "../../modules/info/postfixadmin-panel.yaml" + + t.Run("a real postfixadmin login page is flagged", func(t *testing.T) { + res := runMailAdminModule(t, panel, 200, postfixadminLoginPage) + if len(res.Findings) == 0 { + t.Fatal("expected a finding on the postfixadmin login page") + } + }) + + t.Run("a generic username/password login form is not flagged", func(t *testing.T) { + body := `
    +
    ` + if res := runMailAdminModule(t, panel, 200, body); len(res.Findings) > 0 { + t.Errorf("a generic login with a bare brand mention should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a page that only links to the postfixadmin project is not flagged", func(t *testing.T) { + body := `

    We migrated our mail admin panel away from +PostfixAdmin last year.

    +
    ` + if res := runMailAdminModule(t, panel, 200, body); len(res.Findings) > 0 { + t.Errorf("a prose link without the fUsername/fPassword fields should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not flagged", func(t *testing.T) { + if res := runMailAdminModule(t, panel, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/info/postfixadmin-panel.yaml b/modules/info/postfixadmin-panel.yaml new file mode 100644 index 0000000..2788c1c --- /dev/null +++ b/modules/info/postfixadmin-panel.yaml @@ -0,0 +1,42 @@ +# PostfixAdmin Login Panel Detection Module + +id: postfixadmin-panel +info: + name: PostfixAdmin Login Panel + author: sif + severity: info + description: Detects an exposed PostfixAdmin mail admin login panel + tags: [postfixadmin, mail, mailserver, admin, panel, login, detection, info] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/login.php" + - "{{BaseURL}}/postfixadmin/login.php" + + matchers: + - type: word + part: body + condition: and + words: + - 'name="fUsername"' + - 'name="fPassword"' + + - type: regex + part: body + regex: + - "(?i)postfix\\s*admin" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: postfixadmin_version + part: body + regex: + - "Postfix Admin ([0-9]+\\.[0-9]+(?:\\.[0-9]+)?)" + group: 1 diff --git a/modules/recon/postfixadmin-setup-exposure.yaml b/modules/recon/postfixadmin-setup-exposure.yaml new file mode 100644 index 0000000..bf1d8cf --- /dev/null +++ b/modules/recon/postfixadmin-setup-exposure.yaml @@ -0,0 +1,38 @@ +# PostfixAdmin Setup Page Exposure Detection Module + +id: postfixadmin-setup-exposure +info: + name: PostfixAdmin Setup Page Exposure + author: sif + severity: medium + description: Detects a reachable PostfixAdmin setup.php that discloses hosting/environment checks and install state unauthenticated. Since PostfixAdmin 2.3 the setup password gate blocks remote superadmin creation without the configured setup_password, so this is an information-disclosure and unhardened-install signal rather than a direct authentication bypass. + tags: [postfixadmin, mail, mailserver, admin, setup, install, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/setup.php" + - "{{BaseURL}}/postfixadmin/setup.php" + + matchers: + - type: word + part: body + condition: and + words: + - 'name="setup_password"' + - "Postfix Admin" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: postfixadmin_setup_state + part: body + regex: + - "(You need to have a setup_password hash configured)" + - "(setup_password configured)" + group: 1