diff --git a/internal/modules/directory_listing_exposure_test.go b/internal/modules/directory_listing_exposure_test.go
new file mode 100644
index 0000000..ab896a1
--- /dev/null
+++ b/internal/modules/directory_listing_exposure_test.go
@@ -0,0 +1,240 @@
+package modules_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/vmfunc/sif/internal/modules"
+)
+
+func runDirectoryListingModule(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
+}
+
+func TestDirectoryListingExposureModule(t *testing.T) {
+ const dl = "../../modules/recon/directory-listing-exposure.yaml"
+
+ t.Run("a real nginx autoindex page is flagged", func(t *testing.T) {
+ body := `
+
Index of /uploads/
+
+Index of /uploads/
../
+invoice-2024.pdf 03-Jul-2026 09:12 842311
+dump.sql 03-Jul-2026 09:14 19204
+
+`
+ res := runDirectoryListingModule(t, dl, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a finding for a real nginx autoindex page")
+ }
+ })
+
+ t.Run("a real apache mod_autoindex page is flagged", func(t *testing.T) {
+ body := `
+
+ Index of /backup
+
+
+Index of /backup
+
+Apache/2.4.52 (Ubuntu) Server at example.com Port 80
+`
+ res := runDirectoryListingModule(t, dl, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a finding for a real apache mod_autoindex page")
+ }
+ })
+
+ t.Run("a real python http.server listing is flagged", func(t *testing.T) {
+ body := `
+
+
+
+Directory listing for /files/
+
+
+Directory listing for /files/
+
+
+
+
+`
+ res := runDirectoryListingModule(t, dl, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a finding for a real python http.server listing")
+ }
+ })
+
+ t.Run("a real IIS directory browsing page is flagged", func(t *testing.T) {
+ body := `
+files.example.com - /data/
+
+files.example.com - /data/
+[To Parent Directory]
+7/3/2026 9:12 AM <dir> archive
+7/3/2026 9:14 AM 19204 dump.sql
+
+
+
+`
+ res := runDirectoryListingModule(t, dl, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a finding for a real IIS directory browsing page")
+ }
+ })
+
+ t.Run("a real caddy file_server browse page is flagged", func(t *testing.T) {
+ body := `
+
+/media/
+
+
+
+
Folder Path
+
/media/
+
+
+
+
+`
+ res := runDirectoryListingModule(t, dl, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a finding for a real caddy file_server browse page")
+ }
+ })
+
+ t.Run("a real apache non-fancy (plain) mod_autoindex page is flagged", func(t *testing.T) {
+ // IndexOptions without FancyIndexing: a /- list, no sort-links,
+ // and the parent-dir anchor is rendered with a leading space
+ // (mod_autoindex.c emits `"\"> "` before the text). the parent-dir
+ // alternative must tolerate that surrounding whitespace.
+ body := `
+ Index of /backup
+
+
Index of /backup
+
+Apache/2.4.52 (Ubuntu) Server at example.com Port 80
+`
+ res := runDirectoryListingModule(t, dl, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a finding for a real apache non-fancy mod_autoindex page")
+ }
+ })
+
+ t.Run("a blog post with a slash in its index-of title is not flagged", func(t *testing.T) {
+ body := `
+Index of /r/programming favorites
+
+Index of /r/programming favorites
+A curated index of the best posts from /r/programming this year, sorted by
+upvotes. Bookmark this page.
+
+`
+ if res := runDirectoryListingModule(t, dl, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a prose blog with a slash in the title should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an IIS tutorial mentioning the parent-directory link is not flagged", func(t *testing.T) {
+ body := `
+Tutorial - /wwwroot setup guide
+
+Setting up directory browsing
+Once enabled, users will see a [To Parent Directory] link at the top of the
+listing that navigates up one level.
+
+`
+ if res := runDirectoryListingModule(t, dl, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an IIS tutorial mentioning the bare parent-dir string should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a blog post whose title says index of is not flagged", func(t *testing.T) {
+ body := `
+Index of Human Knowledge: A History
+
+Index of Human Knowledge: A History
+This article is an index of the great libraries of the ancient world, from
+Alexandria to Nineveh, and how each collection organized its scrolls.
+
+`
+ if res := runDirectoryListingModule(t, dl, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a prose page mentioning 'index of' should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a custom 403 page is not flagged", func(t *testing.T) {
+ body := `
+403 Forbidden
+
+Access Denied
+You do not have permission to view this directory listing.
+
+`
+ if res := runDirectoryListingModule(t, dl, 403, body); len(res.Findings) > 0 {
+ t.Errorf("a custom 403 should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a custom 404 page is not flagged", func(t *testing.T) {
+ body := `404 Not FoundNot Found
`
+ if res := runDirectoryListingModule(t, dl, 404, body); len(res.Findings) > 0 {
+ t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an SPA shell page is not flagged", func(t *testing.T) {
+ body := `
+
+My App
+
+
+
+
+`
+ if res := runDirectoryListingModule(t, dl, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an SPA shell should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a plain 200 body is not a leak", func(t *testing.T) {
+ if res := runDirectoryListingModule(t, dl, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
diff --git a/modules/recon/directory-listing-exposure.yaml b/modules/recon/directory-listing-exposure.yaml
new file mode 100644
index 0000000..6f51e62
--- /dev/null
+++ b/modules/recon/directory-listing-exposure.yaml
@@ -0,0 +1,54 @@
+# Directory Listing (Autoindex) Detection Module
+
+id: directory-listing-exposure
+info:
+ name: Directory Listing Exposure
+ author: sif
+ severity: medium
+ description: Detects a browsable directory index (nginx autoindex, apache mod_autoindex, IIS directory browsing, python http.server or caddy file_server) that leaks the file inventory of a directory
+ tags: [exposure, misconfiguration, recon, info-leak]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/"
+ - "{{BaseURL}}/uploads/"
+ - "{{BaseURL}}/files/"
+ - "{{BaseURL}}/backup/"
+ - "{{BaseURL}}/backups/"
+ - "{{BaseURL}}/images/"
+ - "{{BaseURL}}/assets/"
+ - "{{BaseURL}}/static/"
+ - "{{BaseURL}}/media/"
+ - "{{BaseURL}}/downloads/"
+ - "{{BaseURL}}/data/"
+
+ threads: 5
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ # each alternative pairs the "index of" style heading with the listing's
+ # own furniture (a real parent-directory anchor or apache column sort-link),
+ # markup a browsable index emits but prose never does. a blog that merely
+ # renders the phrase "index of /something" in its title and h1 has no such
+ # anchor, so it does not match.
+ - type: regex
+ part: body
+ condition: or
+ regex:
+ # nginx autoindex: Index of /path
... ../
+ - "(?is)\\s*index of\\s+/[^<]*
.*?\\.\\./?"
+ # apache mod_autoindex: Index of /path
... a Parent Directory anchor or a ?C=N;O= sort-link
+ - "(?is)\\s*index of\\s+/[^<]*
.*?\\s*parent directory\\s*"
+ - "(?is)\\s*index of\\s+/[^<]*
.*?\\s*directory listing for\\s+/[^<]*.*?\\s*directory listing for\\s+/"
+ # IIS directory browsing: the parent-dir link must be an actual anchor, not a bare mention
+ - "(?is)\\s*\\[to parent directory\\]\\s*"
+ # caddy file_server browse template: fixed breadcrumbs furniture + listing container
+ - "(?is)
folder path
.*?class='listing"