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

commit af506b3dc546ec59eb630dcc54c1a7275464e1aa
parent 7513c5eb27f652553ae7d81d13dc8d6d616f5dfa
Author: djnn <[email protected]>
Date: Mon, 3 Nov 2025 22:38:45 +0100

wip(osint): adding shodan module

Diffstat:
Mgo.mod | 1+
Mgo.sum | 2++
Mhelpers/strings.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Minventory/quick-run.sh | 4+++-
Mosint/run.go | 20++++++++++++++++++++
Aosint/shodan.go | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

6 files changed, 286 insertions(+), 1 deletion(-)

diff --git a/go.mod b/go.mod
@@ -11,6 +11,7 @@ require (
 	github.com/projectdiscovery/httpx v1.7.1
 	github.com/projectdiscovery/subfinder/v2 v2.9.0
 	github.com/projectdiscovery/wappalyzergo v0.2.48
+	github.com/shadowscatcher/shodan v1.0.8
 	github.com/spf13/cobra v1.10.1
 	github.com/spiral-sec/assetfinder v0.2.0
 )
diff --git a/go.sum b/go.sum
@@ -405,6 +405,8 @@ github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7
 github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
 github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
 github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/shadowscatcher/shodan v1.0.8 h1:oQ4r2zDBYnFkatcgE9tr/uuZb6VgL3V/DI1+lXT0qxo=
+github.com/shadowscatcher/shodan v1.0.8/go.mod h1:8tnu38ME7RJgOfkJt/20JXK7bMvPTYCkLagO3IhI2SI=
 github.com/shirou/gopsutil/v3 v3.24.2 h1:kcR0erMbLg5/3LcInpw0X/rrPSqq4CDPyI6A6ZRC18Y=
 github.com/shirou/gopsutil/v3 v3.24.2/go.mod h1:tSg/594BcA+8UdQU2XcW803GWYgdtauFFPgJCJKZlVk=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
diff --git a/helpers/strings.go b/helpers/strings.go
@@ -1,6 +1,7 @@
 package helper
 
 import (
+	"encoding/json"
 	"strings"
 	"unicode"
 )
@@ -38,3 +39,49 @@ func StringHasUnwantedCharactersForDomainName(url string) bool {
 
 	return false
 }
+
+func ExtractString(m map[string]any, k string) string {
+	if v, ok := m[k]; ok {
+		if s, ok := v.(string); ok {
+			return s
+		}
+	}
+	return ""
+}
+
+func ExtractInt(m map[string]any, k string) int {
+	if v, ok := m[k]; ok {
+		switch t := v.(type) {
+		case json.Number:
+			if i, err := t.Int64(); err == nil {
+				return int(i)
+			}
+			if f, err := t.Float64(); err == nil {
+				return int(f)
+			}
+		case float64:
+			return int(t)
+		case int:
+			return t
+		case int64:
+			return int(t)
+		}
+	}
+	return 0
+}
+
+func ExtractNumberAsFloat(m map[string]any, k string) (float64, bool) {
+	if v, ok := m[k]; ok {
+		switch t := v.(type) {
+		case json.Number:
+			f, err := t.Float64()
+			if err != nil {
+				return 0, false
+			}
+			return f, true
+		case float64:
+			return t, true
+		}
+	}
+	return 0, false
+}
diff --git a/inventory/quick-run.sh b/inventory/quick-run.sh
@@ -9,4 +9,6 @@ set -euo
 
 ./yellow osint -d ${1}
 
-./yellow scan -d ${1}/scans/infra --file ${1}/scans/domains.txt
+./yellow prune -f ${1}/scans/domains.txt -o ${1}/scans/active-web-panels.txt
+
+./yellow scan -d ${1}/scans/infra --file ${1}/scans/active-web-panels.txt
diff --git a/osint/run.go b/osint/run.go
@@ -57,6 +57,24 @@ func (opts OsintOpts) RunCleanup() {
 	}
 }
 
+func (opts OsintOpts) runShodan(domains []string) {
+
+	shodanExec := Shodan{}
+	shodanCfg := make(map[string]any)
+
+	shodanCfg["outfile"] = fmt.Sprintf("%s/shodan.txt", opts.scanPath)
+	shodanExec.Configure(shodanCfg)
+
+	shodanExec.Info(opts.domain)
+	if opts.dryRun || !shodanExec.ShouldRun() {
+		return
+	}
+
+	for _, d := range domains {
+		shodanExec.Run(d)
+	}
+}
+
 func (opts OsintOpts) runGoogleDorks() {
 
 	dorks := Dorks{}
@@ -166,6 +184,8 @@ func (opts OsintOpts) Run() {
 		}
 	}
 
+	/* opts.runShodan(domains) */
+
 	// now add all assets together, line by line
 	uniqueOutfile := fmt.Sprintf("%s/domains.txt", opts.scanPath)
 	err := os.WriteFile(uniqueOutfile, domainBuffer.Bytes(), 0644)
diff --git a/osint/shodan.go b/osint/shodan.go
@@ -0,0 +1,213 @@
+package osint
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"evil.djnn.sh/djnn/yellow/helpers"
+	"github.com/shadowscatcher/shodan"
+	"github.com/shadowscatcher/shodan/search"
+)
+
+type Shodan struct {
+	outfile string
+
+	HTTPClient *http.Client
+	apiKey     string
+}
+
+func (s Shodan) ShouldRun() bool {
+	return s.apiKey != ""
+}
+
+func (s *Shodan) Info(target string) {
+	if s.apiKey == "" {
+		fmt.Println("[!] env value SHODAN_API_KEY not found. skipping shodan scan")
+	} else {
+		fmt.Println("[+] Running shodan on ", target)
+	}
+}
+
+func (s *Shodan) Configure(c any) {
+	s.apiKey = os.Getenv("SHODAN_API_KEY")
+
+	s.outfile = c.(map[string]any)["outfile"].(string)
+	if s.HTTPClient == nil {
+		s.HTTPClient = &http.Client{
+			Timeout: 15 * time.Second,
+		}
+	}
+}
+
+func (s *Shodan) Run(ip string) {
+	ip = strings.TrimSpace(ip)
+	if ip == "" {
+		fmt.Println("err: empty ip provided")
+		return
+	}
+
+	if s.apiKey == "" {
+		fmt.Println("err: shodan api key not configured (set SHODAN_API_KEY or provide apikey in Configure)")
+		return
+	}
+
+	if s.outfile == "" {
+		fmt.Println("err: outfile not configured in Shodan (provide 'outfile' in Configure)")
+		return
+	}
+
+	httpClient := s.HTTPClient
+	if httpClient == nil {
+		httpClient = &http.Client{Timeout: 15 * time.Second}
+	}
+
+	client, err := shodan.GetClient(s.apiKey, httpClient, false)
+	if err != nil {
+		fmt.Printf("err: creating shodan client: %v\n", err)
+		return
+	}
+
+	ctx := context.Background()
+	hostParams := search.HostParams{
+		IP: ip,
+	}
+	hostInfo, err := client.Host(ctx, hostParams)
+	if err != nil {
+		fmt.Printf("err: shodan host lookup failed for %s: %v\n", ip, err)
+		return
+	}
+
+	rawJSON, err := json.Marshal(hostInfo)
+	if err != nil {
+		fmt.Printf("err: marshaling shodan response: %v\n", err)
+		return
+	}
+
+	var pretty bytes.Buffer
+	if err := json.Indent(&pretty, rawJSON, "", "\t"); err != nil {
+		pretty.Write(rawJSON)
+	}
+
+	safeIP := strings.ReplaceAll(ip, ":", "-")
+	outdir := filepath.Dir(s.outfile)
+	if outdir == "." || outdir == "" {
+		outdir = "."
+	}
+	jsonPath := filepath.Join(outdir, fmt.Sprintf("%s.shodan.json", safeIP))
+
+	if err := os.WriteFile(jsonPath, pretty.Bytes(), 0o644); err != nil {
+		fmt.Printf("warning: failed to write json file %s: %v\n", jsonPath, err)
+	} else {
+		fmt.Printf("wrote json: %s\n", jsonPath)
+	}
+
+	summary, perr := buildShodanSummaryFromJSON(bytes.NewReader(pretty.Bytes()), ip)
+	if perr != nil {
+		summary = fmt.Sprintf("IP: %s\n\n(Unable to build structured summary: %v)\n\n%s\n", ip, perr, string(pretty.Bytes()))
+	}
+
+	f, err := os.OpenFile(s.outfile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+	if err != nil {
+		fmt.Printf("err: unable to open outfile %s for append: %v\n", s.outfile, err)
+		return
+	}
+	defer f.Close()
+
+	sep := strings.Repeat("=", 80) + "\n"
+	toWrite := fmt.Sprintf("%s\n%s\n\n", summary, sep)
+	if _, err := f.WriteString(toWrite); err != nil {
+		fmt.Printf("err: writing summary to %s: %v\n", s.outfile, err)
+		return
+	}
+
+	fmt.Printf("[OSINT %s] Shodan summary appended to %s (json: %s)\n", ip, s.outfile, jsonPath)
+
+	// small sleep to be polite in case caller loops over many IPs
+	time.Sleep(200 * time.Millisecond)
+}
+
+func buildShodanSummaryFromJSON(r io.Reader, ip string) (string, error) {
+	var obj map[string]any
+	dec := json.NewDecoder(r)
+	dec.UseNumber()
+	if err := dec.Decode(&obj); err != nil {
+		return "", fmt.Errorf("json decode: %w", err)
+	}
+
+	var b strings.Builder
+	b.WriteString(fmt.Sprintf("Shodan results for %s\n", ip))
+	b.WriteString("=================================\n\n")
+
+	if hosts, ok := obj["hostnames"].([]any); ok && len(hosts) > 0 {
+		names := []string{}
+		for _, h := range hosts {
+			if s, ok := h.(string); ok && s != "" {
+				names = append(names, s)
+			}
+		}
+		if len(names) > 0 {
+			b.WriteString("Hostnames: " + strings.Join(names, ", ") + "\n")
+		}
+	}
+
+	if v, ok := obj["org"].(string); ok && v != "" {
+		b.WriteString("Organization: " + v + "\n")
+	}
+	if v, ok := obj["asn"].(string); ok && v != "" {
+		b.WriteString("ASN: " + v + "\n")
+	}
+	if v, ok := obj["os"].(string); ok && v != "" {
+		b.WriteString("OS: " + v + "\n")
+	}
+	if v, ok := obj["country_name"].(string); ok && v != "" {
+		b.WriteString("Country: " + v + "\n")
+	}
+	if v, ok := obj["city"].(string); ok && v != "" {
+		b.WriteString("City: " + v + "\n")
+	}
+
+	if loc, ok := obj["location"].(map[string]any); ok {
+		lat, lok := helper.ExtractNumberAsFloat(loc, "latitude")
+		lon, rok := helper.ExtractNumberAsFloat(loc, "longitude")
+		if lok && rok {
+			b.WriteString(fmt.Sprintf("Location: %f,%f\n", lat, lon))
+		}
+	}
+
+	if dataArr, ok := obj["data"].([]any); ok && len(dataArr) > 0 {
+		b.WriteString("\nOpen services:\n")
+		for _, e := range dataArr {
+			if m, ok := e.(map[string]any); ok {
+				port := helper.ExtractInt(m, "port")
+				prod := helper.ExtractString(m, "product")
+				banner := helper.ExtractString(m, "data")
+				if port != 0 {
+					b.WriteString(fmt.Sprintf("- Port %d", port))
+				} else {
+					b.WriteString("- (unknown port)")
+				}
+				if prod != "" {
+					b.WriteString(fmt.Sprintf(" — %s", prod))
+				}
+				b.WriteString("\n")
+				if banner != "" {
+					sn := banner
+					if len(sn) > 512 {
+						sn = sn[:512] + "…"
+					}
+					b.WriteString(fmt.Sprintf("  Banner: %s\n", sn))
+				}
+			}
+		}
+	}
+
+	return b.String(), nil
+}