From 94d375fc3ba42a94e6d54a1939f99d2ea456ddee Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:19:43 -0700 Subject: [PATCH] feat(modules): add editor sftp deploy config exposure modules (#198) modules/recon/vscode-sftp-exposure.yaml flags an exposed vscode-sftp config on its tool keys, remotePath and uploadOnSave, then extracts the deploy host. the tool keys keep an unrelated json config that merely carries host and credential fields from matching. modules/recon/sublime-sftp-exposure.yaml flags an exposed Sublime SFTP config on its snake case keys, upload_on_save and sync_down_on_open, and extracts the deploy host. modules/recon/ftpconfig-exposure.yaml flags an exposed remote-ftp config on its connection timeout keys, connTimeout and pasvTimeout, and extracts the deploy host. each module requires a credential field alongside the tool key and rejects an html body, so a login page served on the same path is not a leak and an unrelated json config is not a high severity credential finding. internal/modules/deploy_config_exposure_test.go drives the three modules end to end through ExecuteHTTPModule and asserts the leak alongside the near misses a strict review wants pinned: an html login page carrying the same keys, a plain json config without the tool keys, a tool config with a host but no credential field and a 404, none of which may match. it also pins a key auth config with no password as a leak the credential matcher must still catch. verify: go test ./internal/modules, each matcher, guard and extractor proven to bite (break -> red, restore -> green). --- .../modules/deploy_config_exposure_test.go | 134 ++++++++++++++++++ modules/recon/ftpconfig-exposure.yaml | 55 +++++++ modules/recon/sublime-sftp-exposure.yaml | 57 ++++++++ modules/recon/vscode-sftp-exposure.yaml | 57 ++++++++ 4 files changed, 303 insertions(+) create mode 100644 internal/modules/deploy_config_exposure_test.go create mode 100644 modules/recon/ftpconfig-exposure.yaml create mode 100644 modules/recon/sublime-sftp-exposure.yaml create mode 100644 modules/recon/vscode-sftp-exposure.yaml diff --git a/internal/modules/deploy_config_exposure_test.go b/internal/modules/deploy_config_exposure_test.go new file mode 100644 index 0000000..b2c3a6f --- /dev/null +++ b/internal/modules/deploy_config_exposure_test.go @@ -0,0 +1,134 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dropalldatabases/sif/internal/modules" +) + +func runDeployModule(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 deployExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestDeployConfigExposureModules(t *testing.T) { + const vscode = "../../modules/recon/vscode-sftp-exposure.yaml" + const sublime = "../../modules/recon/sublime-sftp-exposure.yaml" + const ftpconfig = "../../modules/recon/ftpconfig-exposure.yaml" + + t.Run("vscode sftp config leaks the deploy host", func(t *testing.T) { + body := `{"name":"prod","host":"deploy.example.com","protocol":"sftp",` + + `"username":"root","password":"s3cr3t","remotePath":"/var/www","uploadOnSave":true}` + res := runDeployModule(t, vscode, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a vscode sftp finding") + } + if v := deployExtract(res, "remote_host"); v != "deploy.example.com" { + t.Errorf("remote_host=%q, want deploy.example.com", v) + } + }) + + t.Run("vscode sftp config with key auth still flags and extracts the host", func(t *testing.T) { + body := `{"host":"key.example.com","protocol":"sftp",` + + `"username":"deploy","privateKeyPath":"~/.ssh/id_rsa","uploadOnSave":true}` + res := runDeployModule(t, vscode, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a vscode sftp finding for a key-auth config") + } + if v := deployExtract(res, "remote_host"); v != "key.example.com" { + t.Errorf("remote_host=%q, want key.example.com", v) + } + }) + + t.Run("sublime sftp config leaks the deploy host", func(t *testing.T) { + body := `{"type":"sftp","host":"sftp.example.org","user":"www","password":"hunter2",` + + `"remote_path":"/srv","upload_on_save":true,"sync_down_on_open":false}` + res := runDeployModule(t, sublime, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a sublime sftp finding") + } + if v := deployExtract(res, "remote_host"); v != "sftp.example.org" { + t.Errorf("remote_host=%q, want sftp.example.org", v) + } + }) + + t.Run("atom remote-ftp config leaks the deploy host", func(t *testing.T) { + body := `{"protocol":"ftp","host":"ftp.example.net","port":21,"user":"upload",` + + `"pass":"letmein","remote":"/","connTimeout":10000,"pasvTimeout":10000}` + res := runDeployModule(t, ftpconfig, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an atom remote-ftp finding") + } + if v := deployExtract(res, "remote_host"); v != "ftp.example.net" { + t.Errorf("remote_host=%q, want ftp.example.net", v) + } + }) + + t.Run("an html login page carrying the same keys is not a leak", func(t *testing.T) { + body := `Sign in` + + `config keys "remotePath" "password" "host":"evil.example.com"` + if res := runDeployModule(t, vscode, 200, body); len(res.Findings) > 0 { + t.Errorf("an html page should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a plain json config without the tool keys is not a leak", func(t *testing.T) { + body := `{"host":"db.internal","username":"admin","user":"admin","pass":"x","password":"hunter2"}` + for _, file := range []string{vscode, sublime, ftpconfig} { + if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 { + t.Errorf("%s: a config without the tool keys should not match, got %d findings", file, len(res.Findings)) + } + } + }) + + t.Run("a tool config with a host but no credential field is not a leak", func(t *testing.T) { + bodies := map[string]string{ + vscode: `{"host":"h.example.com","remotePath":"/var/www","uploadOnSave":true}`, + sublime: `{"type":"sftp","host":"h.example.com","upload_on_save":true}`, + ftpconfig: `{"protocol":"ftp","host":"h.example.com","connTimeout":10000,"pasvTimeout":10000}`, + } + for file, body := range bodies { + if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 { + t.Errorf("%s: a config with no credential field should not match, got %d findings", file, len(res.Findings)) + } + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + for _, file := range []string{vscode, sublime, ftpconfig} { + if res := runDeployModule(t, file, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings)) + } + } + }) +} diff --git a/modules/recon/ftpconfig-exposure.yaml b/modules/recon/ftpconfig-exposure.yaml new file mode 100644 index 0000000..6df6267 --- /dev/null +++ b/modules/recon/ftpconfig-exposure.yaml @@ -0,0 +1,55 @@ +# Atom remote-ftp Deploy Config Exposure Detection Module + +id: ftpconfig-exposure +info: + name: Atom remote-ftp Deploy Config Exposure + author: sif + severity: high + description: Detects an exposed remote-ftp config that leaks deploy host and credentials + tags: [atom, ftp, sftp, deploy, credentials, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/.ftpconfig" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + condition: or + words: + - '"pasvTimeout"' + - '"connTimeout"' + + - type: word + part: body + condition: or + words: + - '"pass"' + - '"user"' + + - type: word + part: body + negative: true + condition: or + words: + - "" + - "" + + extractors: + - type: regex + name: remote_host + part: body + regex: + - '"host"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/sublime-sftp-exposure.yaml b/modules/recon/sublime-sftp-exposure.yaml new file mode 100644 index 0000000..648d777 --- /dev/null +++ b/modules/recon/sublime-sftp-exposure.yaml @@ -0,0 +1,57 @@ +# Sublime SFTP Deploy Config Exposure Detection Module + +id: sublime-sftp-exposure +info: + name: Sublime SFTP Deploy Config Exposure + author: sif + severity: high + description: Detects an exposed Sublime SFTP config that leaks deploy host and credentials + tags: [sublime, sftp, deploy, credentials, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/sftp-config.json" + - "{{BaseURL}}/sftp-config-alt1.json" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + condition: or + words: + - '"upload_on_save"' + - '"sync_down_on_open"' + - '"save_before_upload"' + + - type: word + part: body + condition: or + words: + - '"password"' + - '"user"' + + - type: word + part: body + negative: true + condition: or + words: + - "<!DOCTYPE" + - "<!doctype" + - "<html" + - "<HTML" + - "<head>" + - "<title>" + + extractors: + - type: regex + name: remote_host + part: body + regex: + - '"host"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/vscode-sftp-exposure.yaml b/modules/recon/vscode-sftp-exposure.yaml new file mode 100644 index 0000000..3a0f586 --- /dev/null +++ b/modules/recon/vscode-sftp-exposure.yaml @@ -0,0 +1,57 @@ +# VSCode SFTP Deploy Config Exposure Detection Module + +id: vscode-sftp-exposure +info: + name: VSCode SFTP Deploy Config Exposure + author: sif + severity: high + description: Detects an exposed vscode-sftp config that leaks deploy host and credentials + tags: [vscode, sftp, deploy, credentials, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/.vscode/sftp.json" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + condition: or + words: + - '"remotePath"' + - '"uploadOnSave"' + - '"syncOption"' + + - type: word + part: body + condition: or + words: + - '"password"' + - '"privateKeyPath"' + - '"username"' + + - type: word + part: body + negative: true + condition: or + words: + - "<!DOCTYPE" + - "<!doctype" + - "<html" + - "<HTML" + - "<head>" + - "<title>" + + extractors: + - type: regex + name: remote_host + part: body + regex: + - '"host"\s*:\s*"([^"]+)"' + group: 1