From 7742b8e731f1ad8f28d7114b4c7346f448742ef1 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:36:10 -0700 Subject: [PATCH] feat(modules): add laravel telescope dashboard exposure module (#279) Detect an exposed Laravel Telescope dashboard, which reveals requests, queries, exceptions and dumped variables. Telescope aborts with 403 before render when gated, so a 200 carrying both the Telescope title and the Vue mount id proves the gate is open. The and-condition on the mount id keeps an unrelated page that merely names telescope, or a soft-404, from matching. --- internal/modules/telescope_exposure_test.go | 53 +++++++++++++++++++ modules/recon/laravel-telescope-exposure.yaml | 37 +++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 internal/modules/telescope_exposure_test.go create mode 100644 modules/recon/laravel-telescope-exposure.yaml diff --git a/internal/modules/telescope_exposure_test.go b/internal/modules/telescope_exposure_test.go new file mode 100644 index 0000000..1da1abc --- /dev/null +++ b/internal/modules/telescope_exposure_test.go @@ -0,0 +1,53 @@ +package modules_test + +import "testing" + +// laravel-telescope-exposure fires only when the dashboard actually renders: a +// 200 carrying both the Telescope title and the Vue mount id. A 403-gated +// install, a soft-404 that reflects the word, and an unrelated "telescope" page +// must all stay silent. +func TestLaravelTelescopeExposureModule(t *testing.T) { + const telescope = "../../modules/recon/laravel-telescope-exposure.yaml" + + dashboard := `Telescope - Acme` + + `
` + + t.Run("an exposed dashboard is flagged and the app name extracted", func(t *testing.T) { + res := runDebugModule(t, telescope, 200, dashboard) + if len(res.Findings) == 0 { + t.Fatal("expected a telescope finding") + } + if v := debugExtract(res, "app_name"); v != "Acme" { + t.Errorf("app_name=%q, want Acme", v) + } + }) + + t.Run("a dashboard without a configured app name still fires", func(t *testing.T) { + body := `Telescope` + + `
` + if res := runDebugModule(t, telescope, 200, body); len(res.Findings) == 0 { + t.Fatal("expected a telescope finding without an app name") + } + }) + + t.Run("a gated install returning 403 is silent", func(t *testing.T) { + if res := runDebugModule(t, telescope, 403, dashboard); len(res.Findings) > 0 { + t.Errorf("403-gated telescope should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("an unrelated telescope page without the vue mount is silent", func(t *testing.T) { + body := `Telescope Reviews 2026` + + `` + if res := runDebugModule(t, telescope, 200, body); len(res.Findings) > 0 { + t.Errorf("astronomy page should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a generic soft-404 shell is silent", func(t *testing.T) { + body := `404 Not Foundnot found` + if res := runDebugModule(t, telescope, 200, body); len(res.Findings) > 0 { + t.Errorf("soft-404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/recon/laravel-telescope-exposure.yaml b/modules/recon/laravel-telescope-exposure.yaml new file mode 100644 index 0000000..0bd7770 --- /dev/null +++ b/modules/recon/laravel-telescope-exposure.yaml @@ -0,0 +1,37 @@ +# Laravel Telescope Dashboard Exposure Detection Module + +id: laravel-telescope-exposure +info: + name: Laravel Telescope Dashboard Exposure + author: sif + severity: high + description: Detects an exposed Laravel Telescope debug dashboard that reveals requests, queries, exceptions, and dumped variables + tags: [laravel, telescope, debug, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/telescope" + - "{{BaseURL}}/telescope/requests" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + words: + - 'Telescope' + - 'id="telescope" v-cloak' + condition: and + + extractors: + - type: regex + name: app_name + part: body + regex: + - '<title>Telescope - ([^<]+)' + group: 1