Files
sif/internal/modules/deploy_config_exposure_test.go
TigahandGitHub 94d375fc3b 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).
2026-06-22 17:19:43 -07:00

135 lines
4.8 KiB
Go

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 := `<html><head><title>Sign in</title></head><body>` +
`config keys "remotePath" "password" "host":"evil.example.com"</body></html>`
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))
}
}
})
}