diff --git a/README.md b/README.md index 530d041..4bf881a 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ write the run's findings out to a file for ci/cd or triage: |------|-------------| | `-sarif` | write a sarif 2.1.0 report to this file | | `-markdown`, `-md` | write a markdown report to this file | +| `-silent` | plain output: chrome to stderr, one finding per line to stdout (for pipelines) | ```bash # scan and emit both a sarif and markdown report @@ -224,6 +225,21 @@ write the run's findings out to a file for ci/cd or triage: sarif output is ingestable by github code scanning; markdown is a readable per-target summary. +### pipe mode + +sif reads targets from stdin and accepts naked hosts, so it drops into a unix pipeline. `-silent` routes all banner/spinner/log chrome to stderr and prints one normalized finding per line (`[severity] target module title`) to stdout: + +```bash +# subfinder feeds hosts, sif probes them, notify ships the findings +subfinder -d example.com | sif -silent -probe | notify +``` + +| flag | description | +|------|-------------| +| stdin | a piped target stream (one host/url per line) is read alongside `-u`/`-f` | + +scheme-less hosts default to `https://`; an explicit `http://`/`https://` is kept; any other scheme (`ftp://`, ...) is rejected. + ### yaml modules list available modules: diff --git a/cmd/sif/main.go b/cmd/sif/main.go index 79d176b..7d94269 100644 --- a/cmd/sif/main.go +++ b/cmd/sif/main.go @@ -52,7 +52,9 @@ func main() { log.Fatal(err) } - if !settings.ApiMode { + // patchnotes print to stdout; skip them in api/silent mode so the only thing + // on stdout is the machine-readable result stream. + if !settings.ApiMode && !settings.Silent { patchnotes.ShowOnce(version) } diff --git a/docs/usage.md b/docs/usage.md index 5bf504e..3810cd8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -21,6 +21,23 @@ read targets from a file (one url per line): ./sif -f targets.txt ``` +### stdin (pipe mode) + +when stdin is a pipe, sif reads one target per line from it, alongside any `-u`/`-f` targets. this lets sif slot into a unix pipeline: + +```bash +subfinder -d example.com | sif -silent -probe | notify +``` + +### naked hosts + +targets without a scheme default to `https://`; an explicit `http://`/`https://` is kept as given. any other scheme (`ftp://`, `file://`, ...) is rejected: + +```bash +./sif -u example.com # scanned as https://example.com +echo example.com | sif -probe # same, over stdin +``` + ## scan options ### directory fuzzing @@ -391,6 +408,14 @@ write a readable markdown report grouped by target, then by module: ./sif -u https://example.com -headers -cors -md report.md ``` +### -silent + +plain output for pipelines: all banner/spinner/log chrome goes to stderr and stdout carries one normalized finding per line, formatted `[severity] target module title`. implies non-interactive (no spinners), so a downstream consumer sees nothing but findings: + +```bash +subfinder -d example.com | sif -silent -probe -sh | notify +``` + ## api options ### -api diff --git a/internal/config/config.go b/internal/config/config.go index d9742b7..e1a67cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,7 @@ type Settings struct { Probe bool SARIF string // path to write a sarif 2.1.0 report to ("" = off) Markdown string // path to write a markdown report to ("" = off) + Silent bool // route chrome to stderr, print one finding per line to stdout Modules string // Comma-separated list of module IDs to run ModuleTags string // Run modules matching these tags AllModules bool // Run all loaded modules @@ -166,6 +167,7 @@ func Parse() *Settings { flagSet.CreateGroup("output", "Output", flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"), flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"), + flagSet.BoolVar(&settings.Silent, "silent", false, "Plain output: chrome to stderr, one finding per line to stdout (for pipelines)"), ) flagSet.CreateGroup("api", "API", diff --git a/internal/finding/finding.go b/internal/finding/finding.go index 7c8a4d7..b917829 100644 --- a/internal/finding/finding.go +++ b/internal/finding/finding.go @@ -41,6 +41,15 @@ type Finding struct { Raw string // short evidence string, not the full body } +// Line renders a finding as one stable, terse, machine-friendly line for the +// -silent plain sink: "[severity] target module title". no styling, no color - +// a downstream pipe (notify, grep, awk) keys off the bracketed severity and the +// fixed field order, so the shape stays frozen. pointer receiver: Finding is +// wide enough that copying it per line is wasteful. +func (f *Finding) Line() string { + return fmt.Sprintf("[%s] %s %s %s", f.Severity, f.Target, f.Module, f.Title) +} + // static per-module severities for results that carry no severity field of // their own. these are the editorial baseline; a scanner that emits its own // severity (cors, xss, nuclei, ...) overrides this on a per-item basis. diff --git a/internal/finding/line_test.go b/internal/finding/line_test.go new file mode 100644 index 0000000..a14beda --- /dev/null +++ b/internal/finding/line_test.go @@ -0,0 +1,48 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import "testing" + +// Line is the -silent wire format; its shape is frozen, so pin it. +func TestFindingLine(t *testing.T) { + tests := []struct { + name string + f Finding + want string + }{ + { + name: "high severity", + f: Finding{Target: "https://x.com", Module: "sql", Severity: SeverityHigh, Title: "admin panel"}, + want: "[high] https://x.com sql admin panel", + }, + { + name: "info recon", + f: Finding{Target: "https://y.com", Module: "headers", Severity: SeverityInfo, Title: "Server"}, + want: "[info] https://y.com headers Server", + }, + { + name: "unknown severity", + f: Finding{Target: "z.com", Module: "mystery", Severity: SeverityUnknown, Title: "?"}, + want: "[unknown] z.com mystery ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.f.Line(); got != tt.want { + t.Errorf("Line() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/output/output.go b/internal/output/output.go index 6f1e01d..251756b 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -14,6 +14,7 @@ package output import ( "fmt" + "io" "os" "strings" @@ -126,13 +127,47 @@ func SetAPIMode(enabled bool) { apiMode = enabled } +// sink is where all banner/spinner/log chrome is written. it defaults to stdout +// so normal runs are unchanged; -silent repoints it at stderr so stdout carries +// nothing but the machine-readable findings a downstream pipe consumes. +var sink io.Writer = os.Stdout + +// silent is the plain-sink mode: chrome goes to stderr and interactive widgets +// (spinners, live progress) are suppressed so a piped consumer never sees them. +var silent bool + +// SetSilent routes all chrome to stderr and marks the run non-interactive. +// findings are printed to stdout by the caller via Finding/PrintFinding; the +// output package itself never touches stdout once silent is on. +func SetSilent(enabled bool) { + silent = enabled + if enabled { + sink = os.Stderr + return + } + sink = os.Stdout +} + +// Silent reports whether plain-sink mode is active. callers gate interactive +// behaviour (spinners, prompts) on this. +func Silent() bool { + return silent +} + +// Writer is the current chrome sink (stdout normally, stderr under -silent). +// callers that render their own chrome (the startup banner) write here so it +// follows the same routing as everything else. +func Writer() io.Writer { + return sink +} + // Info prints an informational message with [*] prefix func Info(format string, args ...interface{}) { if apiMode { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixInfo.Render("[*]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixInfo.Render("[*]"), msg) } // Success prints a success message with [+] prefix @@ -141,7 +176,7 @@ func Success(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixSuccess.Render("[+]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixSuccess.Render("[+]"), msg) } // Warn prints a warning message with [!] prefix @@ -150,7 +185,7 @@ func Warn(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixWarning.Render("[!]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixWarning.Render("[!]"), msg) } // Error prints an error message with [-] prefix @@ -159,7 +194,7 @@ func Error(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixError.Render("[-]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixError.Render("[-]"), msg) } // ScanStart prints a styled scan start message @@ -167,7 +202,7 @@ func ScanStart(scanName string) { if apiMode { return } - fmt.Printf("%s starting %s\n", prefixInfo.Render("[*]"), scanName) + fmt.Fprintf(sink, "%s starting %s\n", prefixInfo.Render("[*]"), scanName) } // ScanComplete prints a styled scan completion message @@ -175,7 +210,7 @@ func ScanComplete(scanName string, resultCount int, resultType string) { if apiMode { return } - fmt.Printf("%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType) + fmt.Fprintf(sink, "%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType) } // Module creates a prefixed logger for a specific module/tool @@ -202,7 +237,7 @@ func (m *ModuleLogger) Info(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", m.prefix(), msg) + fmt.Fprintf(sink, "%s %s\n", m.prefix(), msg) } // Success prints a success message with module prefix @@ -211,7 +246,7 @@ func (m *ModuleLogger) Success(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg) + fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg) } // Warn prints a warning message with module prefix @@ -220,7 +255,7 @@ func (m *ModuleLogger) Warn(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg) + fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg) } // Error prints an error message with module prefix @@ -229,7 +264,7 @@ func (m *ModuleLogger) Error(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg) + fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg) } // Start prints a scan start message with module prefix (adds newline before for separation) @@ -237,7 +272,7 @@ func (m *ModuleLogger) Start() { if apiMode { return } - fmt.Printf("\n%s starting scan\n", m.prefix()) + fmt.Fprintf(sink, "\n%s starting scan\n", m.prefix()) } // Complete prints a scan complete message with module prefix @@ -245,15 +280,16 @@ func (m *ModuleLogger) Complete(resultCount int, resultType string) { if apiMode { return } - fmt.Printf("%s complete (%d %s)\n", m.prefix(), resultCount, resultType) + fmt.Fprintf(sink, "%s complete (%d %s)\n", m.prefix(), resultCount, resultType) } -// ClearLine clears the current line (for progress bar updates) +// ClearLine clears the current line (for progress bar updates). silent mode is +// non-interactive, so there's no live line to clear and stdout stays untouched. func ClearLine() { - if !IsTTY { + if !IsTTY || silent { return } - fmt.Print("\033[2K\r") + fmt.Fprint(sink, "\033[2K\r") } // Summary styles @@ -274,22 +310,22 @@ func PrintSummary(scans []string, logFiles []string) { return } - fmt.Println() - fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────")) - fmt.Println() - fmt.Printf(" %s\n", summaryHeader.Render("SCAN COMPLETE")) - fmt.Println() + fmt.Fprintln(sink) + fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────")) + fmt.Fprintln(sink) + fmt.Fprintf(sink, " %s\n", summaryHeader.Render("SCAN COMPLETE")) + fmt.Fprintln(sink) // Print scans scanList := strings.Join(scans, ", ") - fmt.Printf(" %s %s\n", Muted.Render("Scans:"), scanList) + fmt.Fprintf(sink, " %s %s\n", Muted.Render("Scans:"), scanList) // Print log files if any if len(logFiles) > 0 { - fmt.Printf(" %s %s\n", Muted.Render("Output:"), strings.Join(logFiles, ", ")) + fmt.Fprintf(sink, " %s %s\n", Muted.Render("Output:"), strings.Join(logFiles, ", ")) } - fmt.Println() - fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────")) - fmt.Println() + fmt.Fprintln(sink) + fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────")) + fmt.Fprintln(sink) } diff --git a/internal/output/progress.go b/internal/output/progress.go index 18bab09..ca34fd6 100644 --- a/internal/output/progress.go +++ b/internal/output/progress.go @@ -98,7 +98,7 @@ func (p *Progress) Done() { } func (p *Progress) render() { - if apiMode { + if apiMode || silent { return } @@ -135,7 +135,7 @@ func (p *Progress) render() { p.mu.Unlock() if advanced { - fmt.Printf(" [%d%%] %d/%d\n", percent, current, total) + fmt.Fprintf(sink, " [%d%%] %d/%d\n", percent, current, total) } return } @@ -190,5 +190,5 @@ func (p *Progress) render() { ) ClearLine() - fmt.Print(line) + fmt.Fprint(sink, line) } diff --git a/internal/output/silent_test.go b/internal/output/silent_test.go new file mode 100644 index 0000000..04b5b2e --- /dev/null +++ b/internal/output/silent_test.go @@ -0,0 +1,113 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package output + +import ( + "os" + "strings" + "testing" +) + +// in silent mode chrome must land on stderr and leave stdout untouched, so a +// piped consumer downstream never sees a banner or log line. +func TestSetSilentRoutesChromeToStderr(t *testing.T) { + defer SetSilent(false) + + outStr, errStr := captureStdoutStderr(t, func() { + // SetSilent reads os.Stderr at call time, so swap then set. + SetSilent(true) + Info("scanning %s", "example.com") + Success("done") + }) + + if outStr != "" { + t.Errorf("silent mode wrote chrome to stdout: %q", outStr) + } + if !strings.Contains(errStr, "scanning example.com") { + t.Errorf("silent chrome missing from stderr: %q", errStr) + } +} + +// the default (non-silent) sink is stdout; flipping silent off must restore it. +func TestSetSilentOffRoutesChromeToStdout(t *testing.T) { + outStr, errStr := captureStdoutStderr(t, func() { + SetSilent(false) + Info("hello") + }) + + if !strings.Contains(outStr, "hello") { + t.Errorf("non-silent chrome missing from stdout: %q", outStr) + } + if strings.Contains(errStr, "hello") { + t.Errorf("non-silent chrome leaked to stderr: %q", errStr) + } +} + +// Silent() reflects the toggle so callers can gate interactive widgets. +func TestSilentToggle(t *testing.T) { + defer SetSilent(false) + SetSilent(true) + if !Silent() { + t.Error("Silent() = false after SetSilent(true)") + } + SetSilent(false) + if Silent() { + t.Error("Silent() = true after SetSilent(false)") + } +} + +// captureStdoutStderr swaps both real streams for pipes, runs fn, and returns +// what landed on each. SetSilent reads os.Stdout/os.Stderr at call time, so the +// swap has to happen before fn flips the sink - fn does that itself. +func captureStdoutStderr(t *testing.T, fn func()) (string, string) { + t.Helper() + + outR, outW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + errR, errW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stderr: %v", err) + } + + savedOut, savedErr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = outW, errW + + outCh := drain(outR) + errCh := drain(errR) + + fn() + + os.Stdout, os.Stderr = savedOut, savedErr + outW.Close() + errW.Close() + return <-outCh, <-errCh +} + +func drain(r *os.File) <-chan string { + ch := make(chan string, 1) + go func() { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, rerr := r.Read(tmp) + buf = append(buf, tmp[:n]...) + if rerr != nil { + break + } + } + ch <- string(buf) + }() + return ch +} diff --git a/internal/output/spinner.go b/internal/output/spinner.go index 4aff1bc..019ef1a 100644 --- a/internal/output/spinner.go +++ b/internal/output/spinner.go @@ -14,7 +14,6 @@ package output import ( "fmt" - "os" "sync" "time" ) @@ -42,7 +41,7 @@ func NewSpinner(message string) *Spinner { // Start begins the spinner animation func (s *Spinner) Start() { - if apiMode { + if apiMode || silent { return } @@ -57,7 +56,7 @@ func (s *Spinner) Start() { // In non-TTY mode, just print the message once if !IsTTY { - fmt.Printf(" %s...\n", s.message) + fmt.Fprintf(sink, " %s...\n", s.message) return } @@ -66,7 +65,7 @@ func (s *Spinner) Start() { // Stop halts the spinner and clears the line func (s *Spinner) Stop() { - if apiMode { + if apiMode || silent { return } @@ -112,8 +111,8 @@ func (s *Spinner) animate() { spinnerChar := prefixInfo.Render(spinnerFrames[frame]) line := fmt.Sprintf("\r %s %s", spinnerChar, msg) - fmt.Fprint(os.Stdout, "\033[2K") // Clear line - fmt.Fprint(os.Stdout, line) + fmt.Fprint(sink, "\033[2K") // Clear line + fmt.Fprint(sink, line) frame = (frame + 1) % len(spinnerFrames) } diff --git a/man/sif.1 b/man/sif.1 index e2f2199..145af78 100644 --- a/man/sif.1 +++ b/man/sif.1 @@ -1,5 +1,5 @@ .\" man page for sif - the blazing-fast pentesting suite -.TH sif 1 "2026-06-08" "sif" "sif manual" +.TH sif 1 "2026-06-10" "sif" "sif manual" .SH NAME sif \- blazing-fast pentesting suite .SH SYNOPSIS @@ -15,17 +15,25 @@ sif \- blazing-fast pentesting suite .RI [ scans ] .RI [ options ] .br +.I "targets" +| +.B sif +.RI [ scans ] +.RI [ options ] +.br .B sif .RB { patchnote | version } .SH DESCRIPTION .B sif is a modular recon and exploitation suite. it runs multiple scan types concurrently against one or more targets, and can be extended with yaml -modules. targets must include a +modules. a scheme\-less target defaults to +.B https:// +\&; an explicit .B http:// or .B https:// -scheme. +is kept; any other scheme is rejected. .SH TARGETS .TP .BR \-u ", " \-\-urls " \fIlist\fR" @@ -33,6 +41,13 @@ comma\-separated list of urls to scan. .TP .BR \-f ", " \-\-file " \fIpath\fR" file with one url per line. +.TP +.B stdin +when stdin is a pipe, one target per line is read from it, alongside any +.B \-u +/ +.B \-f +targets. lets sif slot into a unix pipeline (e.g. \fBsubfinder | sif \-silent | notify\fR). .SH SCANS .TP .BR \-dirlist " \fIsize\fR" @@ -171,6 +186,11 @@ write a sarif 2.1.0 report of the run to \fIfile\fR. .BR \-md ", " \-\-markdown " \fIfile\fR" write a markdown report of the run to \fIfile\fR. .TP +.B \-silent +plain output for pipelines: route all chrome to stderr and print one +normalized finding per line to stdout as \fB[severity] target module title\fR. +implies non\-interactive (no spinners). +.TP .B \-api emit json results and suppress the interactive output. .SH MODULES diff --git a/sif.go b/sif.go index 4be3e97..f87e468 100644 --- a/sif.go +++ b/sif.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" @@ -85,13 +86,19 @@ func NewModuleResult[T ScanResult](data T) ModuleResult { func New(settings *config.Settings) (*App, error) { app := &App{settings: settings} + // -silent reroutes all chrome to stderr (and suppresses spinners) before the + // banner prints, so stdout carries nothing but findings even on the banner. + if settings.Silent { + output.SetSilent(true) + } + if !settings.ApiMode { - fmt.Println(output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ ")) + fmt.Fprintln(output.Writer(), output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ ")) tagline := "blazing-fast pentesting suite" if Version != "dev" { tagline += " · v" + Version } - fmt.Println(output.Subheading.Render("\n" + tagline + "\n\nbsd 3-clause · (c) 2022-2026 vmfunc, xyzeva & contributors\n")) + fmt.Fprintln(output.Writer(), output.Subheading.Render("\n"+tagline+"\n\nbsd 3-clause · (c) 2022-2026 vmfunc, xyzeva & contributors\n")) } else { output.SetAPIMode(true) } @@ -101,10 +108,11 @@ func New(settings *config.Settings) (*App, error) { return app, nil } - switch { - case len(settings.URLs) > 0: - app.targets = settings.URLs - case settings.File != "": + // -u and -f are explicit; stdin is additive so `subfinder | sif -u extra` + // still works. order: flags first, then piped lines appended. + app.targets = append(app.targets, settings.URLs...) + + if settings.File != "" { if _, err := os.Stat(settings.File); err != nil { return nil, err } @@ -120,29 +128,105 @@ func New(settings *config.Settings) (*App, error) { for scanner.Scan() { app.targets = append(app.targets, scanner.Text()) } - default: - return nil, fmt.Errorf("target(s) must be supplied with -u or -f\n\nSee 'sif -h' for more information") } - // Validate all URLs early - for _, url := range app.targets { - if err := validateURL(url); err != nil { + // when stdin is a pipe (not a terminal), drain it for targets so sif slots + // into a unix pipeline: `subfinder -d x | sif -silent | notify`. keyed off + // stdin's mode, never stdout - a redirected stdout (>file) is not a pipe in. + piped, err := stdinPipedFn() + if err != nil { + return nil, err + } + if piped { + stdinTargets, err := readTargets(stdinReader) + if err != nil { + return nil, fmt.Errorf("reading targets from stdin: %w", err) + } + app.targets = append(app.targets, stdinTargets...) + } + + if len(app.targets) == 0 { + return nil, fmt.Errorf("target(s) must be supplied with -u, -f, or stdin\n\nSee 'sif -h' for more information") + } + + // normalize every target in place: a naked host gains a default scheme, an + // explicit scheme is kept, genuinely invalid input is rejected early. + for i := 0; i < len(app.targets); i++ { + normalized, err := normalizeTarget(app.targets[i]) + if err != nil { return nil, err } + app.targets[i] = normalized } return app, nil } -// validateURL checks that a URL has a valid HTTP/HTTPS protocol. -func validateURL(url string) error { - if url == "" { - return fmt.Errorf("empty URL provided") +// defaultScheme is prepended to scheme-less targets. https is the safer default +// for recon: it's what modern hosts serve and avoids a cleartext first hop. +const defaultScheme = "https://" + +// stdin ingestion is wired through two seams so it's hermetically testable: the +// pipe check and the reader can be swapped in tests without touching real fds. +var ( + stdinPipedFn = stdinPiped + stdinReader io.Reader = os.Stdin +) + +// stdinPiped reports whether stdin is a pipe/redirect rather than a terminal. +// a char device (the tty) means interactive with no piped input; anything else +// (pipe, file redirect) is treated as a target stream. +func stdinPiped() (bool, error) { + info, err := os.Stdin.Stat() + if err != nil { + return false, fmt.Errorf("stat stdin: %w", err) } - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - return fmt.Errorf("URL %s must include http:// or https:// protocol", url) + return info.Mode()&os.ModeCharDevice == 0, nil +} + +// readTargets scans one target per line from r, dropping blank lines and +// trimming surrounding whitespace. shared by the stdin path; the file path keeps +// its own scanner since it preserves lines verbatim for back-compat. +func readTargets(r io.Reader) ([]string, error) { + var out []string + scanner := bufio.NewScanner(r) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + out = append(out, line) } - return nil + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scanning targets: %w", err) + } + return out, nil +} + +// normalizeTarget canonicalizes a single target. a scheme-less host gets the +// default scheme; an http:// or https:// target is kept as-is. an empty string +// or a non-http(s) scheme (ftp://, file://, ...) is rejected so junk can't slip +// into the scan loop. +func normalizeTarget(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("empty target provided") + } + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + return target, nil + } + // reject anything that carries some other scheme; "://" present but not + // http(s) is a deliberate non-web target, not a naked host. + if strings.Contains(target, "://") { + return "", fmt.Errorf("target %s must use http:// or https:// scheme", target) + } + // a bare "host:port" or path-only token would also be ambiguous; require at + // least a host-looking first segment (no spaces) before defaulting a scheme. + if strings.ContainsAny(target, " \t") { + return "", fmt.Errorf("invalid target %q", target) + } + return defaultScheme + target, nil } // Run runs the pentesting suite, with the targets specified, according to the @@ -562,6 +646,13 @@ func (app *App) Run() error { // count now so the path is live and observable without changing output. log.Debugf("normalized %d findings across %d targets", len(allFindings), len(app.targets)) + // -silent: stdout is the findings stream, one terse line each. all chrome + // already went to stderr via the rerouted sink, so this is the only thing a + // downstream pipe sees. + if app.settings.Silent { + printFindings(allFindings) + } + if wantReport { if err := app.writeReports(reportResults); err != nil { return err @@ -575,6 +666,18 @@ func (app *App) Run() error { return nil } +// printFindings writes one normalized finding per line to stdout for the +// -silent plain sink. a single Builder over the run avoids interleaving with +// any stray stderr chrome and keeps the write to one syscall. +func printFindings(findings []finding.Finding) { + var b strings.Builder + for i := 0; i < len(findings); i++ { + b.WriteString(findings[i].Line()) + b.WriteByte('\n') + } + fmt.Print(b.String()) +} + // collectFindings normalizes one target's module results through finding.Flatten // - the single normalization path that notify and diff (later bundles) build on. // every scan result struct collapses to flat, severity-ranked findings here so a diff --git a/sif_test.go b/sif_test.go index 2a4f3ad..c6455b4 100644 --- a/sif_test.go +++ b/sif_test.go @@ -13,11 +13,24 @@ package sif import ( + "io" + "os" + "strings" "testing" "github.com/dropalldatabases/sif/internal/config" + "github.com/dropalldatabases/sif/internal/finding" ) +// TestMain neutralizes the stdin seam for the whole package so tests that build +// an App via New() never block on the test runner's real stdin (a pipe under +// `go test`). tests that exercise ingestion set the seams explicitly. +func TestMain(m *testing.M) { + stdinPipedFn = func() (bool, error) { return false, nil } + stdinReader = strings.NewReader("") + os.Exit(m.Run()) +} + // mockResult is a test implementation of ScanResult type mockResult struct { name string @@ -117,20 +130,16 @@ func TestNew_URLValidation(t *testing.T) { wantErr: false, }, { + // naked host is now accepted and normalized, not rejected name: "missing protocol", url: "example.com", - wantErr: true, + wantErr: false, }, { name: "invalid protocol", url: "ftp://example.com", wantErr: true, }, - { - name: "empty url", - url: "", - wantErr: true, - }, } for _, tt := range tests { @@ -148,6 +157,194 @@ func TestNew_URLValidation(t *testing.T) { } } +func TestNormalizeTarget(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "naked host defaults https", in: "example.com", want: "https://example.com"}, + {name: "naked host with port", in: "example.com:8443", want: "https://example.com:8443"}, + {name: "naked host with path", in: "example.com/admin", want: "https://example.com/admin"}, + {name: "https kept", in: "https://example.com", want: "https://example.com"}, + {name: "http kept", in: "http://example.com", want: "http://example.com"}, + {name: "surrounding whitespace trimmed", in: " example.com\t", want: "https://example.com"}, + {name: "empty rejected", in: "", wantErr: true}, + {name: "blank rejected", in: " ", wantErr: true}, + {name: "ftp scheme rejected", in: "ftp://example.com", wantErr: true}, + {name: "file scheme rejected", in: "file:///etc/passwd", wantErr: true}, + {name: "embedded space rejected", in: "foo bar", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeTarget(tt.in) + if (err != nil) != tt.wantErr { + t.Fatalf("normalizeTarget(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err != nil { + return + } + if got != tt.want { + t.Errorf("normalizeTarget(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNew_StdinIngestion(t *testing.T) { + // feed a pipe of targets and assert they're parsed and normalized alongside + // the explicit -u target. the seams stand in for a real stdin pipe. + origPiped, origReader := stdinPipedFn, stdinReader + t.Cleanup(func() { stdinPipedFn, stdinReader = origPiped, origReader }) + + stdinPipedFn = func() (bool, error) { return true, nil } + stdinReader = strings.NewReader("sub1.example.com\nhttps://sub2.example.com\n\n sub3.example.com \n") + + settings := &config.Settings{ + URLs: []string{"https://flag.example.com"}, + ApiMode: true, + } + + app, err := New(settings) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + want := []string{ + "https://flag.example.com", + "https://sub1.example.com", + "https://sub2.example.com", + "https://sub3.example.com", + } + if len(app.targets) != len(want) { + t.Fatalf("targets = %v (%d), want %d", app.targets, len(app.targets), len(want)) + } + for i := range want { + if app.targets[i] != want[i] { + t.Errorf("target[%d] = %q, want %q", i, app.targets[i], want[i]) + } + } +} + +func TestNew_StdinOnly(t *testing.T) { + // no -u/-f: a piped stream alone must satisfy the target requirement. + origPiped, origReader := stdinPipedFn, stdinReader + t.Cleanup(func() { stdinPipedFn, stdinReader = origPiped, origReader }) + + stdinPipedFn = func() (bool, error) { return true, nil } + stdinReader = strings.NewReader("only.example.com\n") + + app, err := New(&config.Settings{ApiMode: true}) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + if len(app.targets) != 1 || app.targets[0] != "https://only.example.com" { + t.Errorf("targets = %v, want [https://only.example.com]", app.targets) + } +} + +func TestNew_NoTargets_StdinEmpty(t *testing.T) { + // an empty pipe with no flags is still "no targets" and must error. + origPiped, origReader := stdinPipedFn, stdinReader + t.Cleanup(func() { stdinPipedFn, stdinReader = origPiped, origReader }) + + stdinPipedFn = func() (bool, error) { return true, nil } + stdinReader = strings.NewReader("\n \n") + + if _, err := New(&config.Settings{ApiMode: true}); err == nil { + t.Error("New() should error when stdin yields no targets and no flags set") + } +} + +func TestReadTargets(t *testing.T) { + got, err := readTargets(strings.NewReader("a.com\n\n b.com \nc.com\n")) + if err != nil { + t.Fatalf("readTargets() error: %v", err) + } + want := []string{"a.com", "b.com", "c.com"} + if len(got) != len(want) { + t.Fatalf("readTargets() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("readTargets()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// errReader fails on first read; used to assert stdin scan errors propagate. +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestReadTargets_Error(t *testing.T) { + if _, err := readTargets(errReader{}); err == nil { + t.Error("readTargets() should propagate a reader error") + } +} + +func TestPrintFindings(t *testing.T) { + findings := []finding.Finding{ + {Target: "https://a.com", Module: "sql", Severity: finding.SeverityHigh, Title: "admin panel"}, + {Target: "https://b.com", Module: "headers", Severity: finding.SeverityInfo, Title: "Server"}, + } + + out := captureStdout(t, func() { printFindings(findings) }) + + wantLines := []string{ + "[high] https://a.com sql admin panel", + "[info] https://b.com headers Server", + } + got := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(got) != len(wantLines) { + t.Fatalf("printFindings wrote %d lines, want %d:\n%s", len(got), len(wantLines), out) + } + for i := range wantLines { + if got[i] != wantLines[i] { + t.Errorf("line %d = %q, want %q", i, got[i], wantLines[i]) + } + } +} + +func TestPrintFindings_Empty(t *testing.T) { + out := captureStdout(t, func() { printFindings(nil) }) + if out != "" { + t.Errorf("printFindings(nil) wrote %q, want empty", out) + } +} + +// captureStdout swaps os.Stdout for a pipe, runs fn, and returns what it wrote. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + saved := os.Stdout + os.Stdout = w + + done := make(chan string, 1) + go func() { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, rerr := r.Read(tmp) + buf = append(buf, tmp[:n]...) + if rerr != nil { + break + } + } + done <- string(buf) + }() + + fn() + os.Stdout = saved + w.Close() + return <-done +} + func TestModuleResult_JSON(t *testing.T) { mr := ModuleResult{ Id: "test",