From 9be3d4a51cd92349bcca9b393629f26b4a6dd4e6 Mon Sep 17 00:00:00 2001
From: Tigah <88289044+TBX3D@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:45:48 -0700
Subject: [PATCH] fix(modules): guard ignition, web.config and appsettings
against soft-404 shells (#286)
---
internal/modules/app_config_exposure_test.go | 9 +++++++++
internal/modules/debug_exposure_test.go | 9 +++++++++
.../modules/webserver_config_exposure_test.go | 9 +++++++++
modules/recon/appsettings-exposure.yaml | 10 ++++++++++
modules/recon/laravel-ignition-exposure.yaml | 17 ++++++++++++++---
modules/recon/webconfig-exposure.yaml | 10 ++++++++++
6 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/internal/modules/app_config_exposure_test.go b/internal/modules/app_config_exposure_test.go
index 5b64368..9fc1c48 100644
--- a/internal/modules/app_config_exposure_test.go
+++ b/internal/modules/app_config_exposure_test.go
@@ -141,6 +141,15 @@ func TestAppConfigExposureModules(t *testing.T) {
}
})
+ t.Run("an appsettings docs page that quotes ConnectionStrings and Password= is not a leak", func(t *testing.T) {
+ body := `
schema` +
+ `{ "ConnectionStrings": "string", "note": "e.g. Server=x;Password=changeme;" }` +
+ ``
+ if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an html docs page should not match, got %d findings", len(res.Findings))
+ }
+ })
+
t.Run("prose that names the wp-config password is not a backup", func(t *testing.T) {
body := "set the DB_PASSWORD env var before running the installer"
if res := runAppCfgModule(t, wpconfig, 200, body); len(res.Findings) > 0 {
diff --git a/internal/modules/debug_exposure_test.go b/internal/modules/debug_exposure_test.go
index 02df017..9d84f12 100644
--- a/internal/modules/debug_exposure_test.go
+++ b/internal/modules/debug_exposure_test.go
@@ -103,6 +103,15 @@ func TestDebugExposureModules(t *testing.T) {
}
})
+ t.Run("an spa fallback shell inlining the ignition client bundle is not the endpoint", func(t *testing.T) {
+ body := `App` +
+ ``
+ if res := runDebugModule(t, ignition, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a bundled js property reference should not match, got %d findings", len(res.Findings))
+ }
+ })
+
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{ignition, profiler, heapdump} {
if res := runDebugModule(t, file, 200, "plain"); len(res.Findings) > 0 {
diff --git a/internal/modules/webserver_config_exposure_test.go b/internal/modules/webserver_config_exposure_test.go
index 5c8f9e6..851428a 100644
--- a/internal/modules/webserver_config_exposure_test.go
+++ b/internal/modules/webserver_config_exposure_test.go
@@ -111,6 +111,15 @@ func TestWebserverConfigExposureModules(t *testing.T) {
}
})
+ t.Run("a docs page that embeds a raw web.config sample is not flagged", func(t *testing.T) {
+ body := `IIS docs` +
+ `example:
` +
+ ``
+ if res := runWebSrvModule(t, webconfig, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an html docs page should not match, got %d findings", len(res.Findings))
+ }
+ })
+
t.Run("an html page is not an htaccess", func(t *testing.T) {
body := "xRewriteEngine On AuthType Basic"
if res := runWebSrvModule(t, htaccess, 200, body); len(res.Findings) > 0 {
diff --git a/modules/recon/appsettings-exposure.yaml b/modules/recon/appsettings-exposure.yaml
index 72cc330..84332b9 100644
--- a/modules/recon/appsettings-exposure.yaml
+++ b/modules/recon/appsettings-exposure.yaml
@@ -37,6 +37,16 @@ http:
- "pwd="
- "AccountKey="
+ # a docs/schema page that quotes the same json keys as a code sample trips
+ # the two matchers above with no real appsettings.json present. suppress
+ # any response that opens like an html document; a real appsettings.json
+ # is a plain json object and carries no markup.
+ - type: regex
+ part: body
+ negative: true
+ regex:
+ - '(?i)<(!doctype|html|head|title|body)[ >]'
+
extractors:
- type: regex
name: connection_string
diff --git a/modules/recon/laravel-ignition-exposure.yaml b/modules/recon/laravel-ignition-exposure.yaml
index d3132e7..dbbc1e9 100644
--- a/modules/recon/laravel-ignition-exposure.yaml
+++ b/modules/recon/laravel-ignition-exposure.yaml
@@ -20,10 +20,21 @@ http:
status:
- 200
- - type: word
+ # an spa fallback route that inlines the ignition client bundle references
+ # can_execute_commands as a bare js property with no real health-check
+ # response present. anchor on the actual json shape the endpoint emits
+ # (a quoted key immediately followed by a boolean), which a bundled script
+ # accessing the field as `r.can_execute_commands` never produces.
+ - type: regex
part: body
- words:
- - "can_execute_commands"
+ regex:
+ - '"can_execute_commands"\s*:\s*(?:true|false)'
+
+ - type: regex
+ part: body
+ negative: true
+ regex:
+ - '(?i)<(!doctype|html|head|title|body)[ >]'
extractors:
- type: regex
diff --git a/modules/recon/webconfig-exposure.yaml b/modules/recon/webconfig-exposure.yaml
index 0006a29..42be518 100644
--- a/modules/recon/webconfig-exposure.yaml
+++ b/modules/recon/webconfig-exposure.yaml
@@ -34,6 +34,16 @@ http:
- " and never carries html markup.
+ - type: regex
+ part: body
+ negative: true
+ regex:
+ - '(?i)<(!doctype|html|head|title|body)[ >]'
+
extractors:
- type: regex
name: connection_string