Skip to content

Commit 2cfdc51

Browse files
authored
Merge pull request #47 from vmfunc/feat/shodan-integration
feat: add shodan integration for host reconnaissance
2 parents 816ecd1 + ac879e0 commit 2cfdc51

5 files changed

Lines changed: 542 additions & 1 deletion

File tree

pkg/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Settings struct {
4141
Headers bool
4242
CloudStorage bool
4343
SubdomainTakeover bool
44+
Shodan bool
4445
}
4546

4647
const (
@@ -83,6 +84,7 @@ func Parse() *Settings {
8384
flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"),
8485
flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"),
8586
flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"),
87+
flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"),
8688
)
8789

8890
flagSet.CreateGroup("runtime", "Runtime",

pkg/scan/dork.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork
9797

9898
for i, dork := range dorks {
9999

100-
101100
if i%threads != thread {
102101
continue
103102
}

pkg/scan/shodan.go

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package scan
14+
15+
import (
16+
"encoding/json"
17+
"fmt"
18+
"io"
19+
"net"
20+
"net/http"
21+
"net/url"
22+
"os"
23+
"strings"
24+
"time"
25+
26+
"github.com/charmbracelet/log"
27+
"github.com/dropalldatabases/sif/internal/styles"
28+
"github.com/dropalldatabases/sif/pkg/logger"
29+
)
30+
31+
const shodanBaseURL = "https://api.shodan.io"
32+
33+
// ShodanResult represents the results from a Shodan host lookup
34+
type ShodanResult struct {
35+
IP string `json:"ip_str"`
36+
Hostnames []string `json:"hostnames,omitempty"`
37+
Organization string `json:"org,omitempty"`
38+
ASN string `json:"asn,omitempty"`
39+
ISP string `json:"isp,omitempty"`
40+
Country string `json:"country_name,omitempty"`
41+
City string `json:"city,omitempty"`
42+
OS string `json:"os,omitempty"`
43+
Ports []int `json:"ports,omitempty"`
44+
Vulns []string `json:"vulns,omitempty"`
45+
Services []ShodanService `json:"services,omitempty"`
46+
LastUpdate string `json:"last_update,omitempty"`
47+
}
48+
49+
// ShodanService represents a service found by Shodan
50+
type ShodanService struct {
51+
Port int `json:"port"`
52+
Protocol string `json:"transport"`
53+
Product string `json:"product,omitempty"`
54+
Version string `json:"version,omitempty"`
55+
Banner string `json:"data,omitempty"`
56+
Module string `json:"_shodan,omitempty"`
57+
}
58+
59+
// shodanHostResponse is the raw response from Shodan API
60+
type shodanHostResponse struct {
61+
IP string `json:"ip_str"`
62+
Hostnames []string `json:"hostnames"`
63+
Org string `json:"org"`
64+
ASN string `json:"asn"`
65+
ISP string `json:"isp"`
66+
CountryName string `json:"country_name"`
67+
City string `json:"city"`
68+
OS string `json:"os"`
69+
Ports []int `json:"ports"`
70+
Vulns []string `json:"vulns"`
71+
Data []shodanData `json:"data"`
72+
LastUpdate string `json:"last_update"`
73+
}
74+
75+
type shodanData struct {
76+
Port int `json:"port"`
77+
Transport string `json:"transport"`
78+
Product string `json:"product"`
79+
Version string `json:"version"`
80+
Data string `json:"data"`
81+
Shodan map[string]interface{} `json:"_shodan"`
82+
}
83+
84+
// Shodan performs a Shodan lookup for the given URL
85+
// The API key should be provided via the SHODAN_API_KEY environment variable
86+
func Shodan(targetURL string, timeout time.Duration, logdir string) (*ShodanResult, error) {
87+
fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("Shodan lookup") + "..."))
88+
89+
shodanlog := log.NewWithOptions(os.Stderr, log.Options{
90+
Prefix: "Shodan 🔍",
91+
}).With("url", targetURL)
92+
93+
apiKey := os.Getenv("SHODAN_API_KEY")
94+
if apiKey == "" {
95+
shodanlog.Warn("SHODAN_API_KEY environment variable not set, skipping Shodan lookup")
96+
return nil, fmt.Errorf("SHODAN_API_KEY environment variable not set")
97+
}
98+
99+
// extract hostname from URL
100+
parsedURL, err := url.Parse(targetURL)
101+
if err != nil {
102+
return nil, fmt.Errorf("failed to parse URL: %w", err)
103+
}
104+
hostname := parsedURL.Hostname()
105+
106+
// resolve hostname to IP
107+
ip, err := resolveHostname(hostname)
108+
if err != nil {
109+
shodanlog.Warnf("Failed to resolve hostname %s: %v", hostname, err)
110+
return nil, fmt.Errorf("failed to resolve hostname: %w", err)
111+
}
112+
113+
shodanlog.Infof("Resolved %s to %s", hostname, ip)
114+
115+
// query Shodan API
116+
result, err := queryShodanHost(ip, apiKey, timeout)
117+
if err != nil {
118+
shodanlog.Warnf("Shodan lookup failed: %v", err)
119+
return nil, err
120+
}
121+
122+
// log results
123+
if logdir != "" {
124+
sanitizedURL := strings.Split(targetURL, "://")[1]
125+
if err := logger.WriteHeader(sanitizedURL, logdir, "Shodan lookup"); err != nil {
126+
shodanlog.Errorf("Error writing log header: %v", err)
127+
}
128+
logShodanResults(sanitizedURL, logdir, result)
129+
}
130+
131+
// print results
132+
printShodanResults(shodanlog, result)
133+
134+
return result, nil
135+
}
136+
137+
func resolveHostname(hostname string) (string, error) {
138+
// check if already an IP
139+
if net.ParseIP(hostname) != nil {
140+
return hostname, nil
141+
}
142+
143+
ips, err := net.LookupIP(hostname)
144+
if err != nil {
145+
return "", err
146+
}
147+
148+
// prefer IPv4
149+
for _, ip := range ips {
150+
if ip.To4() != nil {
151+
return ip.String(), nil
152+
}
153+
}
154+
155+
if len(ips) > 0 {
156+
return ips[0].String(), nil
157+
}
158+
159+
return "", fmt.Errorf("no IP addresses found for %s", hostname)
160+
}
161+
162+
func queryShodanHost(ip string, apiKey string, timeout time.Duration) (*ShodanResult, error) {
163+
client := &http.Client{Timeout: timeout}
164+
165+
reqURL := fmt.Sprintf("%s/shodan/host/%s?key=%s", shodanBaseURL, ip, apiKey)
166+
resp, err := client.Get(reqURL)
167+
if err != nil {
168+
return nil, fmt.Errorf("failed to query Shodan: %w", err)
169+
}
170+
defer resp.Body.Close()
171+
172+
if resp.StatusCode == http.StatusUnauthorized {
173+
return nil, fmt.Errorf("invalid Shodan API key")
174+
}
175+
176+
if resp.StatusCode == http.StatusNotFound {
177+
return &ShodanResult{
178+
IP: ip,
179+
}, nil
180+
}
181+
182+
if resp.StatusCode != http.StatusOK {
183+
body, _ := io.ReadAll(resp.Body)
184+
return nil, fmt.Errorf("Shodan API error (status %d): %s", resp.StatusCode, string(body))
185+
}
186+
187+
body, err := io.ReadAll(resp.Body)
188+
if err != nil {
189+
return nil, fmt.Errorf("failed to read response: %w", err)
190+
}
191+
192+
var shodanResp shodanHostResponse
193+
if err := json.Unmarshal(body, &shodanResp); err != nil {
194+
return nil, fmt.Errorf("failed to parse Shodan response: %w", err)
195+
}
196+
197+
// convert to our result type
198+
result := &ShodanResult{
199+
IP: shodanResp.IP,
200+
Hostnames: shodanResp.Hostnames,
201+
Organization: shodanResp.Org,
202+
ASN: shodanResp.ASN,
203+
ISP: shodanResp.ISP,
204+
Country: shodanResp.CountryName,
205+
City: shodanResp.City,
206+
OS: shodanResp.OS,
207+
Ports: shodanResp.Ports,
208+
Vulns: shodanResp.Vulns,
209+
LastUpdate: shodanResp.LastUpdate,
210+
Services: make([]ShodanService, 0, len(shodanResp.Data)),
211+
}
212+
213+
for _, data := range shodanResp.Data {
214+
service := ShodanService{
215+
Port: data.Port,
216+
Protocol: data.Transport,
217+
Product: data.Product,
218+
Version: data.Version,
219+
Banner: truncateBanner(data.Data, 200),
220+
}
221+
if module, ok := data.Shodan["module"].(string); ok {
222+
service.Module = module
223+
}
224+
result.Services = append(result.Services, service)
225+
}
226+
227+
return result, nil
228+
}
229+
230+
func truncateBanner(banner string, maxLen int) string {
231+
banner = strings.TrimSpace(banner)
232+
banner = strings.ReplaceAll(banner, "\r\n", " ")
233+
banner = strings.ReplaceAll(banner, "\n", " ")
234+
235+
if len(banner) > maxLen {
236+
return banner[:maxLen] + "..."
237+
}
238+
return banner
239+
}
240+
241+
func printShodanResults(shodanlog *log.Logger, result *ShodanResult) {
242+
if result.IP != "" {
243+
shodanlog.Infof("IP: %s", styles.Highlight.Render(result.IP))
244+
}
245+
246+
if len(result.Hostnames) > 0 {
247+
shodanlog.Infof("Hostnames: %s", strings.Join(result.Hostnames, ", "))
248+
}
249+
250+
if result.Organization != "" {
251+
shodanlog.Infof("Organization: %s", result.Organization)
252+
}
253+
254+
if result.ISP != "" {
255+
shodanlog.Infof("ISP: %s", result.ISP)
256+
}
257+
258+
if result.Country != "" {
259+
location := result.Country
260+
if result.City != "" {
261+
location = result.City + ", " + result.Country
262+
}
263+
shodanlog.Infof("Location: %s", location)
264+
}
265+
266+
if result.OS != "" {
267+
shodanlog.Infof("OS: %s", result.OS)
268+
}
269+
270+
if len(result.Ports) > 0 {
271+
portStrs := make([]string, len(result.Ports))
272+
for i, port := range result.Ports {
273+
portStrs[i] = fmt.Sprintf("%d", port)
274+
}
275+
shodanlog.Infof("Open Ports: %s", styles.Status.Render(strings.Join(portStrs, ", ")))
276+
}
277+
278+
if len(result.Vulns) > 0 {
279+
shodanlog.Warnf("Vulnerabilities: %s", styles.SeverityHigh.Render(strings.Join(result.Vulns, ", ")))
280+
}
281+
282+
for _, service := range result.Services {
283+
serviceInfo := fmt.Sprintf("%d/%s", service.Port, service.Protocol)
284+
if service.Product != "" {
285+
serviceInfo += " - " + service.Product
286+
if service.Version != "" {
287+
serviceInfo += " " + service.Version
288+
}
289+
}
290+
shodanlog.Infof("Service: %s", serviceInfo)
291+
if service.Banner != "" {
292+
shodanlog.Debugf(" Banner: %s", service.Banner)
293+
}
294+
}
295+
}
296+
297+
func logShodanResults(sanitizedURL string, logdir string, result *ShodanResult) {
298+
var sb strings.Builder
299+
300+
sb.WriteString(fmt.Sprintf("IP: %s\n", result.IP))
301+
302+
if len(result.Hostnames) > 0 {
303+
sb.WriteString(fmt.Sprintf("Hostnames: %s\n", strings.Join(result.Hostnames, ", ")))
304+
}
305+
306+
if result.Organization != "" {
307+
sb.WriteString(fmt.Sprintf("Organization: %s\n", result.Organization))
308+
}
309+
310+
if result.ISP != "" {
311+
sb.WriteString(fmt.Sprintf("ISP: %s\n", result.ISP))
312+
}
313+
314+
if result.Country != "" {
315+
location := result.Country
316+
if result.City != "" {
317+
location = result.City + ", " + result.Country
318+
}
319+
sb.WriteString(fmt.Sprintf("Location: %s\n", location))
320+
}
321+
322+
if result.OS != "" {
323+
sb.WriteString(fmt.Sprintf("OS: %s\n", result.OS))
324+
}
325+
326+
if len(result.Ports) > 0 {
327+
portStrs := make([]string, len(result.Ports))
328+
for i, port := range result.Ports {
329+
portStrs[i] = fmt.Sprintf("%d", port)
330+
}
331+
sb.WriteString(fmt.Sprintf("Open Ports: %s\n", strings.Join(portStrs, ", ")))
332+
}
333+
334+
if len(result.Vulns) > 0 {
335+
sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", strings.Join(result.Vulns, ", ")))
336+
}
337+
338+
for _, service := range result.Services {
339+
serviceInfo := fmt.Sprintf("%d/%s", service.Port, service.Protocol)
340+
if service.Product != "" {
341+
serviceInfo += " - " + service.Product
342+
if service.Version != "" {
343+
serviceInfo += " " + service.Version
344+
}
345+
}
346+
sb.WriteString(fmt.Sprintf("Service: %s\n", serviceInfo))
347+
}
348+
349+
logger.Write(sanitizedURL, logdir, sb.String())
350+
}

0 commit comments

Comments
 (0)