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 20a0948f01166b7952cf158042d5d46047d430eb
parent ce50d31b3a89b356ade1a299ec3385681e6d9ab1
Author: djnn <[email protected]>
Date: Sat, 13 Jun 2026 12:43:19 +0900

fix(meta): many more reliability fixes (cf. CHANGELOG.txt)

Diffstat:
M.github/workflows/release.yml | 2+-
M.gitignore | 1+
MCHANGELOG.txt | 30+++++++++++++++++++++++++-----
MDockerfile | 4++--
MREADME.md | 4++--
Mcli/parser.go | 13+++++++++----
Mhelpers/directories.go | 11++++++++---
Mhelpers/net.go | 10+++++-----
Minventory/quick-run.sh | 4++--
Minventory/run-web-recon | 2+-
Mmain.go | 4+++-
Mosint/run.go | 8++++----
Mscan/cvemap.go | 2+-
Mscan/fingerprint.go | 6+++---
Mscan/gobuster.go | 6++++--
Mscan/httpx.go | 6++++--
Mscan/portscan.go | 9+++++++--
Mscan/run.go | 6+++---

18 files changed, 85 insertions(+), 43 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v5
         with:
-          go-version: 1.25
+          go-version: 1.26
       - name: Run GoReleaser
         uses: goreleaser/goreleaser-action@v6
         with:
diff --git a/.gitignore b/.gitignore
@@ -35,6 +35,7 @@ target.txt
 dist/
 yellow
 nuclei-templates/
+inventory/__pycache__/
 
 # Out files
 .yellow
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
@@ -1,14 +1,34 @@
-## v0.0.7
+## v0.0.6
 
+- fix(scan): perform the real robots.txt fetch (was duplicating the sitemap scan)
+- fix(net): add HTTP client timeouts and close response bodies (prevents hangs and fd leaks)
+- fix(net): keep running when the public IP lookup fails instead of exiting the program
+- fix(helpers): exit cleanly on an unreadable target file instead of a nil-deref panic
+- fix(osint): replace panics and log.Fatal with graceful skips (subfinder, dnsx, file aggregation)
+- fix(scan): skip a target instead of panicking when its output dir cannot be created
+- fix(scan): report port scan write failures instead of silently dropping results
+- fix(scan): check request errors in robots/sitemap and release the gobuster scan context
+- fix(cli): remove the vestigial prune --dir-name flag that collided with --out
+- fix(cli): correct the scan command help to match the tools it actually runs
+- fix(helpers): FolderNameFactory no longer allocates phantom empty folders
+- fix(helpers): report directory-creation failures in the create command instead of silently doing nothing
+- fix(scripts): quick-run.sh now uses set -euo pipefail (pipefail was off and the options table was dumped)
+- fix(scripts): use portable /usr/bin/env shebangs in the inventory scripts
+- fix(scan): convert remaining cvemap/gobuster/httpx panics to graceful skips
+- fix(osint): graceful error on the final domains.txt write instead of panicking
+- fix(cli): prune validates the --out path up front instead of losing results after the work
+- fix(net): trim the trailing newline from the detected public IP
+- chore(cleanup): replace the builtin println with fmt across main, cli, and osint
+- docs(readme): drop the already-implemented robots.txt item from the roadmap
+- chore(cleanup): drop dead code (ParseDomain, AssetfinderConfig) and satisfy staticcheck
+- chore(build): align Go version to 1.26 across Dockerfile, README, and CI
+- chore(build): copy go.sum in the Docker dependency layer for verified builds
 - chore(deps): bump nerva to 1.18.0
 - chore(deps): bump subfinder to 2.14.0
 - chore(deps): bump wappalyzergo to 0.2.84
 - chore(deps): bump vflame6/leaker to 1.6.7
 - chore(deps): bump golang.org/x/* packages (crypto, net, sys, term, text)
 - chore(deps): bump modernc.org/sqlite to 1.51.0, shirou/gopsutil to v4
-
-## v0.0.6
-
 - chore(deps): bump go version to 1.26
 - chore(deps): bump nerva to 1.3.0
 - chore(deps): bump vflame6/leaker to 1.6.4
@@ -16,7 +36,7 @@
 - chore(deps): bump golang.org/x/* packages (crypto, net, sys, text, tools, etc.)
 - chore(deps): bump grpc to 1.80.0, miekg/dns to 1.1.72, klauspost/compress to 1.18.5
 - chore(deps): bump go-exploit to 1.58.0
-- chore(meta): bump nerva libarry to 1.2.0 + using the new --misconfig flag
+- chore(meta): bump nerva library to 1.2.0 + using the new --misconfig flag
 - chore(meta): bump nerva library to 1.1.0
 
 ## v0.0.5
diff --git a/Dockerfile b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25-alpine AS builder
+FROM golang:1.26-alpine AS builder
 
 WORKDIR /app
 RUN apk update --no-cache && \
@@ -7,7 +7,7 @@ RUN apk update --no-cache && \
     make \
     build-base
 
-COPY go.mod .
+COPY go.mod go.sum ./
 RUN go mod download
 
 COPY . .
diff --git a/README.md b/README.md
@@ -49,7 +49,7 @@ For next version, i want to take care of at least two items described here:
 
 ## 2.a Bugfixes
 
-* sitemap should be stored to a file & fetch robots.txt
+* sitemap should be stored to a file
 
 feel free to suggest more ideas. :)~
 if you'd like to do so, reach me by mail or on social media: https://djnn.sh/pgp
@@ -62,7 +62,7 @@ am able to maintain it afterwards.
 
 # 3. Installing
 
-Using go v1.25.
+Using go v1.26.
 
 ```bash
 git clone https://evil.djnn.sh/yellow.git
diff --git a/cli/parser.go b/cli/parser.go
@@ -28,6 +28,12 @@ func GetParser(opts *StandardOptions) *cobra.Command {
 				os.Exit(1)
 			}
 
+			if opts.OutName == "" {
+				fmt.Println("[!] Error: output path cannot be empty. Please run with --out [something]")
+				fmt.Println("\nIf you're confused, feel free to use --help option. :)~")
+				os.Exit(1)
+			}
+
 			pruneOpts := prune.PruneOpts{}
 			pruneOpts.SetProxy(opts.Proxy)
 			pruneOpts.SetDryRun(opts.RunDry)
@@ -56,7 +62,7 @@ func GetParser(opts *StandardOptions) *cobra.Command {
 			helper.DisplayNetInfo()
 
 			if opts.OutName == "" || !helper.Exists(opts.OutName) {
-				println("[!] collected assets will be sent to current working directory")
+				fmt.Println("[!] collected assets will be sent to current working directory")
 			}
 
 			scanOpts := scan.ScanOpts{}
@@ -102,7 +108,7 @@ func GetParser(opts *StandardOptions) *cobra.Command {
 			helper.DisplayNetInfo()
 
 			if opts.OutName == "" || !helper.Exists(opts.OutName) {
-				println("[!] collected assets will be sent to current working directory")
+				fmt.Println("[!] collected assets will be sent to current working directory")
 			}
 
 			scanOpts := scan.ScanOpts{}
@@ -150,7 +156,7 @@ func GetParser(opts *StandardOptions) *cobra.Command {
 			helper.DisplayNetInfo()
 
 			if opts.OutName == "" || !helper.Exists(opts.OutName) {
-				println("[!] collected assets will be sent to current working directory")
+				fmt.Println("[!] collected assets will be sent to current working directory")
 			}
 
 			osintOpts := osint.OsintOpts{}
@@ -233,7 +239,6 @@ func GetParser(opts *StandardOptions) *cobra.Command {
 	scanCmd.Flags().StringVarP(&opts.Ports, "ports", "", defaults.Ports, "Ports to scan, comma-separated or ranges (e.g. 22,80,443,8000-9000)")
 
 	rootCmd.AddCommand(pruneCmd)
-	pruneCmd.Flags().StringVarP(&opts.OutName, "dir-name", "d", defaults.OutName, "Outfile directory name (if no target-file is specified, will also be target domain)")
 	pruneCmd.Flags().StringVarP(&opts.Proxy, "proxy", "p", defaults.Proxy, "Proxy URL (used for the tools supporting it. Other will prompt a warning msg)")
 	pruneCmd.Flags().BoolVarP(&opts.UseHttpInsecure, "insecure", "k", defaults.UseHttpInsecure, "Ignore SSL warnings and force http")
 	pruneCmd.Flags().StringVarP(&opts.TargetFilePath, "file", "f", defaults.TargetFilePath, "File containing list of targets (should be a list of IP Addresses or domains)")
diff --git a/helpers/directories.go b/helpers/directories.go
@@ -14,7 +14,10 @@ func SetUpDirectoryArchitecture(target string) {
 	dirname := ReplaceWithHyphen(target)
 	fmt.Println("[+] setting up directory architecture for", dirname)
 
-	_ = os.MkdirAll(dirname, 0775)
+	if err := os.MkdirAll(dirname, 0775); err != nil {
+		fmt.Printf("[!] could not create %s: %v\n", dirname, err)
+		return
+	}
 	CreateDirectory(dirname, []Folder{
 		{
 			Name:     "scans",
@@ -39,7 +42,9 @@ func Exists(path string) bool {
 func CreateDirectory(base string, folders []Folder) {
 	for _, f := range folders {
 		current := fmt.Sprintf("%s/%s", base, f.Name)
-		_ = os.Mkdir(current, 0775)
+		if err := os.Mkdir(current, 0775); err != nil && !os.IsExist(err) {
+			fmt.Printf("[!] could not create %s: %v\n", current, err)
+		}
 		if len(f.Children) != 0 {
 			CreateDirectory(current, f.Children)
 		}
@@ -47,7 +52,7 @@ func CreateDirectory(base string, folders []Folder) {
 }
 
 func FolderNameFactory(names ...string) []Folder {
-	f := make([]Folder, len(names))
+	f := make([]Folder, 0, len(names))
 	for _, name := range names {
 		f = append(f, Folder{Name: name})
 	}
diff --git a/helpers/net.go b/helpers/net.go
@@ -58,18 +58,18 @@ func GetCurrentIP() string {
 	resp, err := cli.Do(req)
 
 	if err != nil || resp == nil {
-		fmt.Printf("[!] Could not query public IP address: %s\n", err.Error())
-		return ""
+		fmt.Printf("[!] Could not query public IP address: %v\n", err)
+		return "127.0.0.1" // service offline or unreachable, we should be able to keep going
 	}
 	defer resp.Body.Close()
 
 	result, err := io.ReadAll(resp.Body)
 	if err != nil {
-		fmt.Printf("[!] Could not IP address: %s\n", err.Error())
-		return ""
+		fmt.Printf("[!] Could not read public IP address: %v\n", err)
+		return "127.0.0.1"
 	}
 
-	return string(result)
+	return strings.TrimSpace(string(result))
 }
 
 func DisplayNetInfo() {
diff --git a/inventory/quick-run.sh b/inventory/quick-run.sh
@@ -1,6 +1,6 @@
-#!/bin/env bash
+#!/usr/bin/env bash
 
-set -euo
+set -euo pipefail
 
 
 ./yellow --help || make
diff --git a/inventory/run-web-recon b/inventory/run-web-recon
@@ -1,4 +1,4 @@
-#!/bin/env python3
+#!/usr/bin/env python3
 
 """
 HTB recon script — uses yellow to set up the environment and run an initial
diff --git a/main.go b/main.go
@@ -1,6 +1,8 @@
 package main
 
 import (
+	"fmt"
+
 	"evil.djnn.sh/djnn/yellow/cli"
 )
 
@@ -36,7 +38,7 @@ const ASCII_ART = `
 `
 
 func main() {
-	println(ASCII_ART)
+	fmt.Print(ASCII_ART)
 
 	opts := cli.GetDefaultOptions()
 	parser := cli.GetParser(&opts)
diff --git a/osint/run.go b/osint/run.go
@@ -45,7 +45,7 @@ func (opts *OsintOpts) SetEmailsFile(data string) {
 }
 
 func (opts OsintOpts) RunCleanup() {
-	println("[+] cleaning up...")
+	fmt.Println("[+] cleaning up...")
 
 	/* remove superfluous files */
 	asfFilepath := fmt.Sprintf("%s/assetfinder.txt", opts.scanPath)
@@ -210,9 +210,9 @@ func (opts OsintOpts) Run() {
 
 	// now add all assets together, line by line
 	uniqueOutfile := fmt.Sprintf("%s/domains.txt", opts.scanPath)
-	err := os.WriteFile(uniqueOutfile, domainBuffer.Bytes(), 0644)
-	if err != nil {
-		panic(err)
+	if err := os.WriteFile(uniqueOutfile, domainBuffer.Bytes(), 0644); err != nil {
+		fmt.Printf("[!] osint: could not write %s: %v\n", uniqueOutfile, err)
+		return
 	}
 
 	fmt.Printf("[OSINT %s] Registered %v IP addresses and assets.\n", opts.domain, len(domains))
diff --git a/scan/cvemap.go b/scan/cvemap.go
@@ -225,6 +225,6 @@ func (c *Cvemap) saveResults(cves []nvdCVE) {
 
 	filename := c.scanPath + "/cves.json"
 	if err := os.WriteFile(filename, data, 0644); err != nil {
-		panic(err)
+		fmt.Printf("[!] cvemap: could not write %s: %v\n", filename, err)
 	}
 }
diff --git a/scan/fingerprint.go b/scan/fingerprint.go
@@ -26,9 +26,9 @@ func (opts *ScanOpts) Fingerprint() {
 		pathForDomain := opts.scanPath + "/" + opts.domain
 		opts.SetScanPath(pathForDomain)
 
-		err := os.MkdirAll(opts.scanPath, 0755)
-		if err != nil {
-			panic(err)
+		if err := os.MkdirAll(opts.scanPath, 0755); err != nil {
+			fmt.Printf("[!] fingerprint: could not create %s, skipping %s: %v\n", opts.scanPath, opts.domain, err)
+			return
 		}
 
 		opts.SetDomain(fullDomain)
diff --git a/scan/gobuster.go b/scan/gobuster.go
@@ -53,7 +53,8 @@ func (g *Gobuster) Run(rawUrl string) {
 
 	u, err := url.Parse(rawUrl)
 	if err != nil {
-		panic("[gobuster] Invalid URL: " + rawUrl)
+		fmt.Printf("[!] gobuster: invalid URL %s: %v\n", rawUrl, err)
+		return
 	}
 
 	pluginOpts := gobusterdir.NewOptions()
@@ -70,7 +71,8 @@ func (g *Gobuster) Run(rawUrl string) {
 	log := libgobuster.NewLogger(globalOpts.Debug)
 	plugin, err := gobusterdir.New(&globalOpts, pluginOpts, log)
 	if err != nil {
-		panic("[gobuster] cannot load plugin: " + err.Error())
+		fmt.Printf("[!] gobuster: cannot load plugin: %v\n", err)
+		return
 	}
 
 	mainContext, cancel := context.WithCancel(context.Background())
diff --git a/scan/httpx.go b/scan/httpx.go
@@ -43,12 +43,14 @@ func (h *Httpx) Run(domain string) {
 	}
 
 	if err := options.ValidateOptions(); err != nil {
-		panic(err)
+		fmt.Printf("[!] httpx: invalid options for %s: %v\n", domain, err)
+		return
 	}
 
 	httpxRunner, err := runner.New(&options)
 	if err != nil {
-		panic(err)
+		fmt.Printf("[!] httpx: could not create runner for %s: %v\n", domain, err)
+		return
 	}
 	defer httpxRunner.Close()
 
diff --git a/scan/portscan.go b/scan/portscan.go
@@ -53,7 +53,10 @@ func (opts ScanOpts) runPortScan() {
 	}
 	fmt.Printf("[+] Running port scan on %s\n", opts.domain)
 	if !opts.dryRun {
-		os.MkdirAll(ps.scanPath, 0755)
+		if err := os.MkdirAll(ps.scanPath, 0755); err != nil {
+			fmt.Printf("[!] port scan: could not create %s: %v\n", ps.scanPath, err)
+			return
+		}
 		ps.Run(opts.domain)
 	}
 }
@@ -84,7 +87,9 @@ func (p *PortScanner) Run(host string) {
 	}
 
 	data, _ := json.MarshalIndent(out, "", "  ")
-	os.WriteFile(p.scanPath+"/portscan.json", data, 0644)
+	if err := os.WriteFile(p.scanPath+"/portscan.json", data, 0644); err != nil {
+		fmt.Printf("[!] port scan: could not write %s/portscan.json: %v\n", p.scanPath, err)
+	}
 
 	fmt.Printf("[SCAN %s] port scan done — %d open ports\n\n", host, len(open))
 }
diff --git a/scan/run.go b/scan/run.go
@@ -180,9 +180,9 @@ func (opts *ScanOpts) Run() {
 		fmt.Printf("[SCAN %s] web panel online....running web scans\n", opts.domain)
 
 		pathForDomain := opts.scanPath + "/" + opts.domain
-		err := os.MkdirAll(pathForDomain, 0755)
-		if err != nil {
-			panic(err)
+		if err := os.MkdirAll(pathForDomain, 0755); err != nil {
+			fmt.Printf("[!] scan: could not create %s, skipping %s: %v\n", pathForDomain, opts.domain, err)
+			return
 		}
 
 		/* modifying opts.scanPath directly would cause nested directories */