+`
+
+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