From 015f4583bb0dc66dee6f74d460e9fbbad35eadbf Mon Sep 17 00:00:00 2001
From: Tigah <88289044+TBX3D@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:46:43 -0700
Subject: [PATCH] feat(modules): add node inspector, rails routes and openapi
exposure modules (#293)
---
.../framework_introspection_exposure_test.go | 176 ++++++++++++++++++
modules/recon/node-inspector-exposure.yaml | 47 +++++
modules/recon/openapi-spec-exposure.yaml | 53 ++++++
modules/recon/rails-routes-exposure.yaml | 31 +++
4 files changed, 307 insertions(+)
create mode 100644 internal/modules/framework_introspection_exposure_test.go
create mode 100644 modules/recon/node-inspector-exposure.yaml
create mode 100644 modules/recon/openapi-spec-exposure.yaml
create mode 100644 modules/recon/rails-routes-exposure.yaml
diff --git a/internal/modules/framework_introspection_exposure_test.go b/internal/modules/framework_introspection_exposure_test.go
new file mode 100644
index 0000000..eb39d60
--- /dev/null
+++ b/internal/modules/framework_introspection_exposure_test.go
@@ -0,0 +1,176 @@
+package modules_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/vmfunc/sif/internal/modules"
+)
+
+func runIntrospectionModule(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 introspectionExtract(res *modules.Result, key string) string {
+ for _, f := range res.Findings {
+ if v := f.Extracted[key]; v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func TestNodeInspectorExposureModule(t *testing.T) {
+ const node = "../../modules/recon/node-inspector-exposure.yaml"
+
+ const openInspector = `[ {
+ "description": "node.js instance",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?ws=127.0.0.1:9229/9b0e",
+ "id": "9b0e",
+ "title": "node[12345]",
+ "type": "node",
+ "url": "file:///app/server.js",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:9229/9b0e"
+} ]`
+
+ t.Run("an open inspector list fires and yields the ws debugger url", func(t *testing.T) {
+ res := runIntrospectionModule(t, node, 200, openInspector)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a node inspector finding")
+ }
+ if v := introspectionExtract(res, "ws_debugger_url"); v != "ws://127.0.0.1:9229/9b0e" {
+ t.Errorf("ws_debugger_url=%q, want ws://127.0.0.1:9229/9b0e", v)
+ }
+ if v := introspectionExtract(res, "inspector_title"); v != "node[12345]" {
+ t.Errorf("inspector_title=%q, want node[12345]", v)
+ }
+ })
+
+ t.Run("prose naming the field without the ws value is not an open inspector", func(t *testing.T) {
+ body := `
attach a client to the webSocketDebuggerUrl and devtoolsFrontendUrl fields`
+ if res := runIntrospectionModule(t, node, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a prose mention should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("the ws url alone without the devtools frontend key does not fire", func(t *testing.T) {
+ body := `[{"webSocketDebuggerUrl":"ws://127.0.0.1:9229/x"}]`
+ if res := runIntrospectionModule(t, node, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a partial marker should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a 404 is not a leak", func(t *testing.T) {
+ if res := runIntrospectionModule(t, node, 404, "not found"); len(res.Findings) > 0 {
+ t.Errorf("a 404 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 := runIntrospectionModule(t, node, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
+
+func TestRailsRoutesExposureModule(t *testing.T) {
+ const rails = "../../modules/recon/rails-routes-exposure.yaml"
+
+ const routesPage = `Routes
+| Helper | HTTP Verb | Path | Controller#Action |
+| root_path | GET | / | home#index |
+`
+
+ t.Run("an exposed dev routes page fires", func(t *testing.T) {
+ res := runIntrospectionModule(t, rails, 200, routesPage)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a rails routes finding")
+ }
+ })
+
+ t.Run("prose naming controller#action without the verb header does not fire", func(t *testing.T) {
+ body := `each route maps to a Controller#Action in rails`
+ if res := runIntrospectionModule(t, rails, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a prose mention should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a production routing error is not a leak", func(t *testing.T) {
+ body := `ActionController::RoutingError (No route matches [GET] "/rails/info/routes")`
+ if res := runIntrospectionModule(t, rails, 404, body); len(res.Findings) > 0 {
+ t.Errorf("a 404 routing error 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 := runIntrospectionModule(t, rails, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
+
+func TestOpenAPISpecExposureModule(t *testing.T) {
+ const openapi = "../../modules/recon/openapi-spec-exposure.yaml"
+
+ t.Run("an openapi 3 spec fires and yields title and version", func(t *testing.T) {
+ body := `{"openapi":"3.0.1","info":{"title":"Orders API","version":"1.2.0"},"paths":{"/orders":{"get":{}}}}`
+ res := runIntrospectionModule(t, openapi, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected an openapi finding")
+ }
+ if v := introspectionExtract(res, "api_title"); v != "Orders API" {
+ t.Errorf("api_title=%q, want Orders API", v)
+ }
+ if v := introspectionExtract(res, "api_version"); v != "3.0.1" {
+ t.Errorf("api_version=%q, want 3.0.1", v)
+ }
+ })
+
+ t.Run("a swagger 2 spec fires", func(t *testing.T) {
+ body := `{"swagger":"2.0","info":{"title":"Legacy API"},"paths":{"/v1/orders":{}}}`
+ if res := runIntrospectionModule(t, openapi, 200, body); len(res.Findings) == 0 {
+ t.Fatal("expected a swagger 2 finding")
+ }
+ })
+
+ t.Run("a version field with no paths object does not fire", func(t *testing.T) {
+ body := `{"openapi":"3.0.1"}`
+ if res := runIntrospectionModule(t, openapi, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a version without paths should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("prose naming openapi without the version format does not fire", func(t *testing.T) {
+ body := `we publish an openapi spec with paths documented here`
+ if res := runIntrospectionModule(t, openapi, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a prose mention should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a 404 is not a leak", func(t *testing.T) {
+ if res := runIntrospectionModule(t, openapi, 404, "not found"); len(res.Findings) > 0 {
+ t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
diff --git a/modules/recon/node-inspector-exposure.yaml b/modules/recon/node-inspector-exposure.yaml
new file mode 100644
index 0000000..b67220d
--- /dev/null
+++ b/modules/recon/node-inspector-exposure.yaml
@@ -0,0 +1,47 @@
+# Node.js V8 Inspector Exposure Detection Module
+
+id: node-inspector-exposure
+info:
+ name: Node.js V8 Inspector Exposure
+ author: sif
+ severity: high
+ description: Detects an open node.js v8 inspector whose websocket debugger allows arbitrary code execution in the process
+ tags: [node, inspector, debugger, rce, exposure, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/json"
+ - "{{BaseURL}}/json/list"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: regex
+ part: body
+ regex:
+ - '"webSocketDebuggerUrl"\s*:\s*"ws://'
+
+ - type: word
+ part: body
+ words:
+ - "\"devtoolsFrontendUrl\""
+
+ extractors:
+ - type: regex
+ name: ws_debugger_url
+ part: body
+ regex:
+ - '"webSocketDebuggerUrl"\s*:\s*"(ws://[^"]+)"'
+ group: 1
+
+ - type: regex
+ name: inspector_title
+ part: body
+ regex:
+ - '"title"\s*:\s*"([^"]+)"'
+ group: 1
diff --git a/modules/recon/openapi-spec-exposure.yaml b/modules/recon/openapi-spec-exposure.yaml
new file mode 100644
index 0000000..23dd705
--- /dev/null
+++ b/modules/recon/openapi-spec-exposure.yaml
@@ -0,0 +1,53 @@
+# OpenAPI/Swagger Specification Exposure Detection Module
+
+id: openapi-spec-exposure
+info:
+ name: OpenAPI/Swagger Specification Exposure
+ author: sif
+ severity: low
+ description: Detects a publicly readable openapi/swagger specification that discloses the full api surface
+ tags: [openapi, swagger, api, exposure, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/openapi.json"
+ - "{{BaseURL}}/v3/api-docs"
+ - "{{BaseURL}}/v2/api-docs"
+ - "{{BaseURL}}/swagger.json"
+ - "{{BaseURL}}/api-docs"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: regex
+ part: body
+ condition: or
+ regex:
+ - '"openapi"\s*:\s*"3(\.[0-9]+)+"'
+ - '"swagger"\s*:\s*"2(\.[0-9]+)+"'
+
+ - type: word
+ part: body
+ words:
+ - "\"paths\""
+
+ extractors:
+ - type: regex
+ name: api_title
+ part: body
+ regex:
+ - '"title"\s*:\s*"([^"]+)"'
+ group: 1
+
+ - type: regex
+ name: api_version
+ part: body
+ regex:
+ - '"openapi"\s*:\s*"([0-9.]+)"'
+ - '"swagger"\s*:\s*"([0-9.]+)"'
+ group: 1
diff --git a/modules/recon/rails-routes-exposure.yaml b/modules/recon/rails-routes-exposure.yaml
new file mode 100644
index 0000000..b6cdc84
--- /dev/null
+++ b/modules/recon/rails-routes-exposure.yaml
@@ -0,0 +1,31 @@
+# Rails Routes Info Page Exposure Detection Module
+
+id: rails-routes-exposure
+info:
+ name: Rails Routes Info Page Exposure
+ author: sif
+ severity: medium
+ description: Detects an exposed rails /rails/info/routes page that only renders in development mode and leaks the full route map
+ tags: [rails, ruby, debug, routes, exposure, misconfiguration, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/rails/info/routes"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: word
+ part: body
+ words:
+ - "Controller#Action"
+
+ - type: word
+ part: body
+ words:
+ - "HTTP Verb"