yellow.git


yellow

~ opinionated web scanning pipeline for pentesters in a hurry
git clone https://github.com/djnnvx/yellow.git
log | files | refs | archive | README | LICENSE

shodan.go (6043B)

      1 package osint
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"encoding/json"
      7 	"fmt"
      8 	"io"
      9 	"net"
     10 	"net/http"
     11 	"os"
     12 	"path/filepath"
     13 	"strings"
     14 	"time"
     15 
     16 	"evil.djnn.sh/djnn/yellow/core"
     17 	"evil.djnn.sh/djnn/yellow/helpers"
     18 	"github.com/shadowscatcher/shodan"
     19 	"github.com/shadowscatcher/shodan/search"
     20 )
     21 
     22 type Shodan struct {
     23 	outfile string
     24 
     25 	HTTPClient *http.Client
     26 	apiKey     string
     27 	queried    map[string]bool
     28 }
     29 
     30 func (s Shodan) ShouldRun() bool {
     31 	return s.apiKey != ""
     32 }
     33 
     34 func (s *Shodan) Info(target string) {
     35 	if s.apiKey == "" {
     36 		fmt.Println("[!] env value SHODAN_API_KEY not found. skipping shodan scan")
     37 	} else {
     38 		fmt.Println("[+] Running shodan on ", target)
     39 	}
     40 }
     41 
     42 func (*Shodan) Name() string { return "shodan" }
     43 
     44 func (s *Shodan) Run(ctx *core.Context) error {
     45 	s.apiKey = os.Getenv("SHODAN_API_KEY")
     46 	s.outfile = fmt.Sprintf("%s/shodan.txt", ctx.ScanPath)
     47 	s.queried = map[string]bool{}
     48 	if s.HTTPClient == nil {
     49 		s.HTTPClient = &http.Client{Timeout: 15 * time.Second}
     50 	}
     51 
     52 	s.Info(ctx.Domain)
     53 	if ctx.DryRun || !s.ShouldRun() {
     54 		return nil
     55 	}
     56 
     57 	for _, d := range ctx.Domains {
     58 		s.scanIP(d)
     59 	}
     60 	return nil
     61 }
     62 
     63 func (s *Shodan) scanIP(ip string) {
     64 	ip = strings.TrimSpace(ip)
     65 	if ip == "" {
     66 		fmt.Println("err: empty ip provided")
     67 		return
     68 	}
     69 
     70 	if net.ParseIP(ip) == nil {
     71 		addrs, err := net.LookupHost(ip)
     72 		if err != nil || len(addrs) == 0 {
     73 			fmt.Printf("err: could not resolve %s to an IP: %v\n", ip, err)
     74 			return
     75 		}
     76 		ip = addrs[0]
     77 	}
     78 
     79 	if s.queried[ip] {
     80 		return
     81 	}
     82 	s.queried[ip] = true
     83 
     84 	if cdn := helper.LookupCDN(net.ParseIP(ip)); helper.SkipPortScan(cdn) {
     85 		fmt.Printf("[OSINT %s] behind %s/%s, skipping shodan lookup\n", ip, cdn.Type, cdn.Provider)
     86 		return
     87 	}
     88 
     89 	if s.apiKey == "" {
     90 		fmt.Println("err: shodan api key not configured (set SHODAN_API_KEY or provide apikey in Configure)")
     91 		return
     92 	}
     93 
     94 	if s.outfile == "" {
     95 		fmt.Println("err: outfile not configured in Shodan (provide 'outfile' in Configure)")
     96 		return
     97 	}
     98 
     99 	httpClient := s.HTTPClient
    100 	if httpClient == nil {
    101 		httpClient = &http.Client{Timeout: 15 * time.Second}
    102 	}
    103 
    104 	client, err := shodan.GetClient(s.apiKey, httpClient, false)
    105 	if err != nil {
    106 		fmt.Printf("err: creating shodan client: %v\n", err)
    107 		return
    108 	}
    109 
    110 	ctx := context.Background()
    111 	hostParams := search.HostParams{
    112 		IP: ip,
    113 	}
    114 	hostInfo, err := client.Host(ctx, hostParams)
    115 	if err != nil {
    116 		fmt.Printf("err: shodan host lookup failed for %s: %v\n", ip, err)
    117 		return
    118 	}
    119 
    120 	rawJSON, err := json.Marshal(hostInfo)
    121 	if err != nil {
    122 		fmt.Printf("err: marshaling shodan response: %v\n", err)
    123 		return
    124 	}
    125 
    126 	var pretty bytes.Buffer
    127 	if err := json.Indent(&pretty, rawJSON, "", "\t"); err != nil {
    128 		pretty.Write(rawJSON)
    129 	}
    130 
    131 	safeIP := strings.ReplaceAll(ip, ":", "-")
    132 	outdir := filepath.Dir(s.outfile)
    133 	if outdir == "." || outdir == "" {
    134 		outdir = "."
    135 	}
    136 	jsonPath := filepath.Join(outdir, fmt.Sprintf("%s.shodan.json", safeIP))
    137 
    138 	if err := os.WriteFile(jsonPath, pretty.Bytes(), 0o644); err != nil {
    139 		fmt.Printf("warning: failed to write json file %s: %v\n", jsonPath, err)
    140 	} else {
    141 		fmt.Printf("wrote json: %s\n", jsonPath)
    142 	}
    143 
    144 	summary, perr := buildShodanSummaryFromJSON(bytes.NewReader(pretty.Bytes()), ip)
    145 	if perr != nil {
    146 		summary = fmt.Sprintf("IP: %s\n\n(Unable to build structured summary: %v)\n\n%s\n", ip, perr, pretty.String())
    147 	}
    148 
    149 	f, err := os.OpenFile(s.outfile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
    150 	if err != nil {
    151 		fmt.Printf("err: unable to open outfile %s for append: %v\n", s.outfile, err)
    152 		return
    153 	}
    154 	defer f.Close()
    155 
    156 	sep := strings.Repeat("=", 80) + "\n"
    157 	toWrite := fmt.Sprintf("%s\n%s\n\n", summary, sep)
    158 	if _, err := f.WriteString(toWrite); err != nil {
    159 		fmt.Printf("err: writing summary to %s: %v\n", s.outfile, err)
    160 		return
    161 	}
    162 
    163 	fmt.Printf("[OSINT %s] Shodan summary appended to %s (json: %s)\n", ip, s.outfile, jsonPath)
    164 
    165 	// small sleep to be polite in case caller loops over many IPs
    166 	time.Sleep(1 * time.Second)
    167 }
    168 
    169 func buildShodanSummaryFromJSON(r io.Reader, ip string) (string, error) {
    170 	var obj map[string]any
    171 	dec := json.NewDecoder(r)
    172 	dec.UseNumber()
    173 	if err := dec.Decode(&obj); err != nil {
    174 		return "", fmt.Errorf("json decode: %w", err)
    175 	}
    176 
    177 	var b strings.Builder
    178 	b.WriteString(fmt.Sprintf("Shodan results for %s\n", ip))
    179 	b.WriteString("=================================\n\n")
    180 
    181 	if hosts, ok := obj["hostnames"].([]any); ok && len(hosts) > 0 {
    182 		names := []string{}
    183 		for _, h := range hosts {
    184 			if s, ok := h.(string); ok && s != "" {
    185 				names = append(names, s)
    186 			}
    187 		}
    188 		if len(names) > 0 {
    189 			b.WriteString("Hostnames: " + strings.Join(names, ", ") + "\n")
    190 		}
    191 	}
    192 
    193 	if v, ok := obj["org"].(string); ok && v != "" {
    194 		b.WriteString("Organization: " + v + "\n")
    195 	}
    196 	if v, ok := obj["asn"].(string); ok && v != "" {
    197 		b.WriteString("ASN: " + v + "\n")
    198 	}
    199 	if v, ok := obj["os"].(string); ok && v != "" {
    200 		b.WriteString("OS: " + v + "\n")
    201 	}
    202 	if v, ok := obj["country_name"].(string); ok && v != "" {
    203 		b.WriteString("Country: " + v + "\n")
    204 	}
    205 	if v, ok := obj["city"].(string); ok && v != "" {
    206 		b.WriteString("City: " + v + "\n")
    207 	}
    208 
    209 	if loc, ok := obj["location"].(map[string]any); ok {
    210 		lat, lok := helper.ExtractNumberAsFloat(loc, "latitude")
    211 		lon, rok := helper.ExtractNumberAsFloat(loc, "longitude")
    212 		if lok && rok {
    213 			b.WriteString(fmt.Sprintf("Location: %f,%f\n", lat, lon))
    214 		}
    215 	}
    216 
    217 	if dataArr, ok := obj["data"].([]any); ok && len(dataArr) > 0 {
    218 		b.WriteString("\nOpen services:\n")
    219 		for _, e := range dataArr {
    220 			if m, ok := e.(map[string]any); ok {
    221 				port := helper.ExtractInt(m, "port")
    222 				prod := helper.ExtractString(m, "product")
    223 				banner := helper.ExtractString(m, "data")
    224 				if port != 0 {
    225 					b.WriteString(fmt.Sprintf("- Port %d", port))
    226 				} else {
    227 					b.WriteString("- (unknown port)")
    228 				}
    229 				if prod != "" {
    230 					b.WriteString(fmt.Sprintf(" — %s", prod))
    231 				}
    232 				b.WriteString("\n")
    233 				if banner != "" {
    234 					sn := banner
    235 					if len(sn) > 512 {
    236 						sn = sn[:512] + "…"
    237 					}
    238 					b.WriteString(fmt.Sprintf("  Banner: %s\n", sn))
    239 				}
    240 			}
    241 		}
    242 	}
    243 
    244 	return b.String(), nil
    245 }