Merge pull request #47 from vmfunc/feat/shodan-integration

feat: add shodan integration for host reconnaissance
This commit is contained in:
Celeste Hickenlooper
2026-01-02 18:35:56 -08:00
committed by GitHub
5 changed files with 542 additions and 1 deletions

View File

@@ -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",

View File

@@ -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
}

350
pkg/scan/shodan.go Normal file
View File

@@ -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())
}

180
pkg/scan/shodan_test.go Normal file
View File

@@ -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)
}
}

10
sif.go
View File

@@ -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)