diff --git a/pkg/config/config.go b/pkg/config/config.go index 030bcbf..2b76969 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,6 +41,7 @@ type Settings struct { Headers bool CloudStorage bool SubdomainTakeover bool + Shodan bool } const ( @@ -83,6 +84,7 @@ func Parse() *Settings { flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"), flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"), flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"), + flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"), ) flagSet.CreateGroup("runtime", "Runtime", diff --git a/pkg/scan/dork.go b/pkg/scan/dork.go index a3a98ed..3e7f026 100644 --- a/pkg/scan/dork.go +++ b/pkg/scan/dork.go @@ -97,7 +97,6 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork for i, dork := range dorks { - if i%threads != thread { continue } diff --git a/pkg/scan/shodan.go b/pkg/scan/shodan.go new file mode 100644 index 0000000..b6b6997 --- /dev/null +++ b/pkg/scan/shodan.go @@ -0,0 +1,350 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/styles" + "github.com/dropalldatabases/sif/pkg/logger" +) + +const shodanBaseURL = "https://api.shodan.io" + +// ShodanResult represents the results from a Shodan host lookup +type ShodanResult struct { + IP string `json:"ip_str"` + Hostnames []string `json:"hostnames,omitempty"` + Organization string `json:"org,omitempty"` + ASN string `json:"asn,omitempty"` + ISP string `json:"isp,omitempty"` + Country string `json:"country_name,omitempty"` + City string `json:"city,omitempty"` + OS string `json:"os,omitempty"` + Ports []int `json:"ports,omitempty"` + Vulns []string `json:"vulns,omitempty"` + Services []ShodanService `json:"services,omitempty"` + LastUpdate string `json:"last_update,omitempty"` +} + +// ShodanService represents a service found by Shodan +type ShodanService struct { + Port int `json:"port"` + Protocol string `json:"transport"` + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + Banner string `json:"data,omitempty"` + Module string `json:"_shodan,omitempty"` +} + +// shodanHostResponse is the raw response from Shodan API +type shodanHostResponse struct { + IP string `json:"ip_str"` + Hostnames []string `json:"hostnames"` + Org string `json:"org"` + ASN string `json:"asn"` + ISP string `json:"isp"` + CountryName string `json:"country_name"` + City string `json:"city"` + OS string `json:"os"` + Ports []int `json:"ports"` + Vulns []string `json:"vulns"` + Data []shodanData `json:"data"` + LastUpdate string `json:"last_update"` +} + +type shodanData struct { + Port int `json:"port"` + Transport string `json:"transport"` + Product string `json:"product"` + Version string `json:"version"` + Data string `json:"data"` + Shodan map[string]interface{} `json:"_shodan"` +} + +// Shodan performs a Shodan lookup for the given URL +// The API key should be provided via the SHODAN_API_KEY environment variable +func Shodan(targetURL string, timeout time.Duration, logdir string) (*ShodanResult, error) { + fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("Shodan lookup") + "...")) + + shodanlog := log.NewWithOptions(os.Stderr, log.Options{ + Prefix: "Shodan 🔍", + }).With("url", targetURL) + + apiKey := os.Getenv("SHODAN_API_KEY") + if apiKey == "" { + shodanlog.Warn("SHODAN_API_KEY environment variable not set, skipping Shodan lookup") + return nil, fmt.Errorf("SHODAN_API_KEY environment variable not set") + } + + // extract hostname from URL + parsedURL, err := url.Parse(targetURL) + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + hostname := parsedURL.Hostname() + + // resolve hostname to IP + ip, err := resolveHostname(hostname) + if err != nil { + shodanlog.Warnf("Failed to resolve hostname %s: %v", hostname, err) + return nil, fmt.Errorf("failed to resolve hostname: %w", err) + } + + shodanlog.Infof("Resolved %s to %s", hostname, ip) + + // query Shodan API + result, err := queryShodanHost(ip, apiKey, timeout) + if err != nil { + shodanlog.Warnf("Shodan lookup failed: %v", err) + return nil, err + } + + // log results + if logdir != "" { + sanitizedURL := strings.Split(targetURL, "://")[1] + if err := logger.WriteHeader(sanitizedURL, logdir, "Shodan lookup"); err != nil { + shodanlog.Errorf("Error writing log header: %v", err) + } + logShodanResults(sanitizedURL, logdir, result) + } + + // print results + printShodanResults(shodanlog, result) + + return result, nil +} + +func resolveHostname(hostname string) (string, error) { + // check if already an IP + if net.ParseIP(hostname) != nil { + return hostname, nil + } + + ips, err := net.LookupIP(hostname) + if err != nil { + return "", err + } + + // prefer IPv4 + for _, ip := range ips { + if ip.To4() != nil { + return ip.String(), nil + } + } + + if len(ips) > 0 { + return ips[0].String(), nil + } + + return "", fmt.Errorf("no IP addresses found for %s", hostname) +} + +func queryShodanHost(ip string, apiKey string, timeout time.Duration) (*ShodanResult, error) { + client := &http.Client{Timeout: timeout} + + reqURL := fmt.Sprintf("%s/shodan/host/%s?key=%s", shodanBaseURL, ip, apiKey) + resp, err := client.Get(reqURL) + if err != nil { + return nil, fmt.Errorf("failed to query Shodan: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid Shodan API key") + } + + if resp.StatusCode == http.StatusNotFound { + return &ShodanResult{ + IP: ip, + }, nil + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Shodan API error (status %d): %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var shodanResp shodanHostResponse + if err := json.Unmarshal(body, &shodanResp); err != nil { + return nil, fmt.Errorf("failed to parse Shodan response: %w", err) + } + + // convert to our result type + result := &ShodanResult{ + IP: shodanResp.IP, + Hostnames: shodanResp.Hostnames, + Organization: shodanResp.Org, + ASN: shodanResp.ASN, + ISP: shodanResp.ISP, + Country: shodanResp.CountryName, + City: shodanResp.City, + OS: shodanResp.OS, + Ports: shodanResp.Ports, + Vulns: shodanResp.Vulns, + LastUpdate: shodanResp.LastUpdate, + Services: make([]ShodanService, 0, len(shodanResp.Data)), + } + + for _, data := range shodanResp.Data { + service := ShodanService{ + Port: data.Port, + Protocol: data.Transport, + Product: data.Product, + Version: data.Version, + Banner: truncateBanner(data.Data, 200), + } + if module, ok := data.Shodan["module"].(string); ok { + service.Module = module + } + result.Services = append(result.Services, service) + } + + return result, nil +} + +func truncateBanner(banner string, maxLen int) string { + banner = strings.TrimSpace(banner) + banner = strings.ReplaceAll(banner, "\r\n", " ") + banner = strings.ReplaceAll(banner, "\n", " ") + + if len(banner) > maxLen { + return banner[:maxLen] + "..." + } + return banner +} + +func printShodanResults(shodanlog *log.Logger, result *ShodanResult) { + if result.IP != "" { + shodanlog.Infof("IP: %s", styles.Highlight.Render(result.IP)) + } + + if len(result.Hostnames) > 0 { + shodanlog.Infof("Hostnames: %s", strings.Join(result.Hostnames, ", ")) + } + + if result.Organization != "" { + shodanlog.Infof("Organization: %s", result.Organization) + } + + if result.ISP != "" { + shodanlog.Infof("ISP: %s", result.ISP) + } + + if result.Country != "" { + location := result.Country + if result.City != "" { + location = result.City + ", " + result.Country + } + shodanlog.Infof("Location: %s", location) + } + + if result.OS != "" { + shodanlog.Infof("OS: %s", result.OS) + } + + if len(result.Ports) > 0 { + portStrs := make([]string, len(result.Ports)) + for i, port := range result.Ports { + portStrs[i] = fmt.Sprintf("%d", port) + } + shodanlog.Infof("Open Ports: %s", styles.Status.Render(strings.Join(portStrs, ", "))) + } + + if len(result.Vulns) > 0 { + shodanlog.Warnf("Vulnerabilities: %s", styles.SeverityHigh.Render(strings.Join(result.Vulns, ", "))) + } + + for _, service := range result.Services { + serviceInfo := fmt.Sprintf("%d/%s", service.Port, service.Protocol) + if service.Product != "" { + serviceInfo += " - " + service.Product + if service.Version != "" { + serviceInfo += " " + service.Version + } + } + shodanlog.Infof("Service: %s", serviceInfo) + if service.Banner != "" { + shodanlog.Debugf(" Banner: %s", service.Banner) + } + } +} + +func logShodanResults(sanitizedURL string, logdir string, result *ShodanResult) { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("IP: %s\n", result.IP)) + + if len(result.Hostnames) > 0 { + sb.WriteString(fmt.Sprintf("Hostnames: %s\n", strings.Join(result.Hostnames, ", "))) + } + + if result.Organization != "" { + sb.WriteString(fmt.Sprintf("Organization: %s\n", result.Organization)) + } + + if result.ISP != "" { + sb.WriteString(fmt.Sprintf("ISP: %s\n", result.ISP)) + } + + if result.Country != "" { + location := result.Country + if result.City != "" { + location = result.City + ", " + result.Country + } + sb.WriteString(fmt.Sprintf("Location: %s\n", location)) + } + + if result.OS != "" { + sb.WriteString(fmt.Sprintf("OS: %s\n", result.OS)) + } + + if len(result.Ports) > 0 { + portStrs := make([]string, len(result.Ports)) + for i, port := range result.Ports { + portStrs[i] = fmt.Sprintf("%d", port) + } + sb.WriteString(fmt.Sprintf("Open Ports: %s\n", strings.Join(portStrs, ", "))) + } + + if len(result.Vulns) > 0 { + sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", strings.Join(result.Vulns, ", "))) + } + + for _, service := range result.Services { + serviceInfo := fmt.Sprintf("%d/%s", service.Port, service.Protocol) + if service.Product != "" { + serviceInfo += " - " + service.Product + if service.Version != "" { + serviceInfo += " " + service.Version + } + } + sb.WriteString(fmt.Sprintf("Service: %s\n", serviceInfo)) + } + + logger.Write(sanitizedURL, logdir, sb.String()) +} diff --git a/pkg/scan/shodan_test.go b/pkg/scan/shodan_test.go new file mode 100644 index 0000000..fe0f022 --- /dev/null +++ b/pkg/scan/shodan_test.go @@ -0,0 +1,180 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestResolveHostname_IP(t *testing.T) { + ip, err := resolveHostname("8.8.8.8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ip != "8.8.8.8" { + t.Errorf("expected '8.8.8.8', got '%s'", ip) + } +} + +func TestResolveHostname_Hostname(t *testing.T) { + ip, err := resolveHostname("localhost") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ip != "127.0.0.1" && ip != "::1" { + t.Errorf("expected localhost to resolve to 127.0.0.1 or ::1, got '%s'", ip) + } +} + +func TestTruncateBanner(t *testing.T) { + tests := []struct { + input string + maxLen int + expected string + }{ + {"short", 10, "short"}, + {"this is a long banner", 10, "this is a ..."}, + {"with\nnewlines\r\n", 50, "with newlines"}, + {" trimmed ", 50, "trimmed"}, + } + + for _, tt := range tests { + result := truncateBanner(tt.input, tt.maxLen) + if result != tt.expected { + t.Errorf("truncateBanner(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected) + } + } +} + +func TestQueryShodanHost_NotFound(t *testing.T) { + // this test verifies that a mock server returning 404 is handled correctly + // note: we can't easily override the const shodanBaseURL for testing + // so this is more of a documentation of expected behavior + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // the actual API query would return a partial result with just the IP + // when Shodan has no data for a host +} + +func TestQueryShodanHost_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := shodanHostResponse{ + IP: "93.184.216.34", + Hostnames: []string{"example.com"}, + Org: "EDGECAST", + ASN: "AS15133", + ISP: "Edgecast Inc.", + CountryName: "United States", + City: "Los Angeles", + Ports: []int{80, 443}, + Data: []shodanData{ + { + Port: 80, + Transport: "tcp", + Product: "nginx", + Version: "1.18.0", + Data: "HTTP/1.1 200 OK\r\nServer: nginx", + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Note: This test would need the actual API endpoint to be overridable + // For now, we just verify the response parsing +} + +func TestShodanResult_Fields(t *testing.T) { + result := ShodanResult{ + IP: "93.184.216.34", + Hostnames: []string{"example.com"}, + Organization: "EDGECAST", + ASN: "AS15133", + ISP: "Edgecast Inc.", + Country: "United States", + City: "Los Angeles", + Ports: []int{80, 443}, + Services: []ShodanService{ + { + Port: 80, + Protocol: "tcp", + Product: "nginx", + Version: "1.18.0", + }, + }, + } + + if result.IP != "93.184.216.34" { + t.Errorf("expected IP '93.184.216.34', got '%s'", result.IP) + } + if len(result.Hostnames) != 1 || result.Hostnames[0] != "example.com" { + t.Errorf("expected hostnames ['example.com'], got %v", result.Hostnames) + } + if result.Organization != "EDGECAST" { + t.Errorf("expected org 'EDGECAST', got '%s'", result.Organization) + } + if len(result.Ports) != 2 { + t.Errorf("expected 2 ports, got %d", len(result.Ports)) + } + if len(result.Services) != 1 { + t.Errorf("expected 1 service, got %d", len(result.Services)) + } +} + +func TestShodanService_Fields(t *testing.T) { + service := ShodanService{ + Port: 443, + Protocol: "tcp", + Product: "OpenSSL", + Version: "1.1.1", + Banner: "TLS handshake", + Module: "https", + } + + if service.Port != 443 { + t.Errorf("expected port 443, got %d", service.Port) + } + if service.Protocol != "tcp" { + t.Errorf("expected protocol 'tcp', got '%s'", service.Protocol) + } + if service.Product != "OpenSSL" { + t.Errorf("expected product 'OpenSSL', got '%s'", service.Product) + } +} + +func TestShodan_NoAPIKey(t *testing.T) { + // ensure no API key is set + originalKey := "" + // Note: we can't easily test this without setting/unsetting env vars + // which could affect other tests. This is just a placeholder. + _ = originalKey +} + +func TestShodanIntegration(t *testing.T) { + // This would be an integration test with the real Shodan API + // Skipping in unit tests + t.Skip("Integration test - requires valid SHODAN_API_KEY") + + _, err := Shodan("https://example.com", 10*time.Second, "") + if err != nil { + t.Logf("Shodan lookup failed (expected without API key): %v", err) + } +} diff --git a/sif.go b/sif.go index d95a2a7..1752f3d 100644 --- a/sif.go +++ b/sif.go @@ -247,6 +247,16 @@ func (app *App) Run() error { } } + if app.settings.Shodan { + result, err := scan.Shodan(url, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running Shodan lookup: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, ModuleResult{"shodan", result}) + scansRun = append(scansRun, "Shodan") + } + } + if app.settings.SubdomainTakeover { // Pass the dnsResults to the SubdomainTakeover function result, err := scan.SubdomainTakeover(url, dnsResults, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)