feat: pipe mode (stdin targets, naked-host, -silent plain output)

sif can now slot into unix pipelines. stdin is drained for targets when
it's a pipe (keyed off stdin's mode, not stdout), alongside -u/-f. naked
hosts are accepted and default to https://; explicit http(s) is kept,
other schemes rejected. -silent routes all banner/spinner/log chrome to
stderr and prints one normalized finding per line to stdout via
finding.Flatten, so `subfinder | sif -silent | notify` works.
This commit is contained in:
vmfunc
2026-06-10 15:49:24 -07:00
parent 0383a7bcd2
commit ef0408ee8d
13 changed files with 632 additions and 62 deletions
+16
View File
@@ -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 | | `-sarif` | write a sarif 2.1.0 report to this file |
| `-markdown`, `-md` | write a markdown 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 ```bash
# scan and emit both a sarif and markdown report # 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. 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 ### yaml modules
list available modules: list available modules:
+3 -1
View File
@@ -52,7 +52,9 @@ func main() {
log.Fatal(err) 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) patchnotes.ShowOnce(version)
} }
+25
View File
@@ -21,6 +21,23 @@ read targets from a file (one url per line):
./sif -f targets.txt ./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 ## scan options
### directory fuzzing ### 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 ./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 options
### -api ### -api
+2
View File
@@ -65,6 +65,7 @@ type Settings struct {
Probe bool Probe bool
SARIF string // path to write a sarif 2.1.0 report to ("" = off) SARIF string // path to write a sarif 2.1.0 report to ("" = off)
Markdown string // path to write a markdown 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 Modules string // Comma-separated list of module IDs to run
ModuleTags string // Run modules matching these tags ModuleTags string // Run modules matching these tags
AllModules bool // Run all loaded modules AllModules bool // Run all loaded modules
@@ -166,6 +167,7 @@ func Parse() *Settings {
flagSet.CreateGroup("output", "Output", flagSet.CreateGroup("output", "Output",
flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"), 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.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", flagSet.CreateGroup("api", "API",
+9
View File
@@ -41,6 +41,15 @@ type Finding struct {
Raw string // short evidence string, not the full body 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 // 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 // their own. these are the editorial baseline; a scanner that emits its own
// severity (cors, xss, nuclei, ...) overrides this on a per-item basis. // severity (cors, xss, nuclei, ...) overrides this on a per-item basis.
+48
View File
@@ -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)
}
})
}
}
+61 -25
View File
@@ -14,6 +14,7 @@ package output
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
@@ -126,13 +127,47 @@ func SetAPIMode(enabled bool) {
apiMode = enabled 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 // Info prints an informational message with [*] prefix
func Info(format string, args ...interface{}) { func Info(format string, args ...interface{}) {
if apiMode { if apiMode {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // Success prints a success message with [+] prefix
@@ -141,7 +176,7 @@ func Success(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // Warn prints a warning message with [!] prefix
@@ -150,7 +185,7 @@ func Warn(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // Error prints an error message with [-] prefix
@@ -159,7 +194,7 @@ func Error(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // ScanStart prints a styled scan start message
@@ -167,7 +202,7 @@ func ScanStart(scanName string) {
if apiMode { if apiMode {
return 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 // ScanComplete prints a styled scan completion message
@@ -175,7 +210,7 @@ func ScanComplete(scanName string, resultCount int, resultType string) {
if apiMode { if apiMode {
return 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 // Module creates a prefixed logger for a specific module/tool
@@ -202,7 +237,7 @@ func (m *ModuleLogger) Info(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // Success prints a success message with module prefix
@@ -211,7 +246,7 @@ func (m *ModuleLogger) Success(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // Warn prints a warning message with module prefix
@@ -220,7 +255,7 @@ func (m *ModuleLogger) Warn(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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 // Error prints an error message with module prefix
@@ -229,7 +264,7 @@ func (m *ModuleLogger) Error(format string, args ...interface{}) {
return return
} }
msg := fmt.Sprintf(format, args...) 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) // Start prints a scan start message with module prefix (adds newline before for separation)
@@ -237,7 +272,7 @@ func (m *ModuleLogger) Start() {
if apiMode { if apiMode {
return 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 // Complete prints a scan complete message with module prefix
@@ -245,15 +280,16 @@ func (m *ModuleLogger) Complete(resultCount int, resultType string) {
if apiMode { if apiMode {
return 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() { func ClearLine() {
if !IsTTY { if !IsTTY || silent {
return return
} }
fmt.Print("\033[2K\r") fmt.Fprint(sink, "\033[2K\r")
} }
// Summary styles // Summary styles
@@ -274,22 +310,22 @@ func PrintSummary(scans []string, logFiles []string) {
return return
} }
fmt.Println() fmt.Fprintln(sink)
fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────")) fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Println() fmt.Fprintln(sink)
fmt.Printf(" %s\n", summaryHeader.Render("SCAN COMPLETE")) fmt.Fprintf(sink, " %s\n", summaryHeader.Render("SCAN COMPLETE"))
fmt.Println() fmt.Fprintln(sink)
// Print scans // Print scans
scanList := strings.Join(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 // Print log files if any
if len(logFiles) > 0 { 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.Fprintln(sink)
fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────")) fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Println() fmt.Fprintln(sink)
} }
+3 -3
View File
@@ -98,7 +98,7 @@ func (p *Progress) Done() {
} }
func (p *Progress) render() { func (p *Progress) render() {
if apiMode { if apiMode || silent {
return return
} }
@@ -135,7 +135,7 @@ func (p *Progress) render() {
p.mu.Unlock() p.mu.Unlock()
if advanced { if advanced {
fmt.Printf(" [%d%%] %d/%d\n", percent, current, total) fmt.Fprintf(sink, " [%d%%] %d/%d\n", percent, current, total)
} }
return return
} }
@@ -190,5 +190,5 @@ func (p *Progress) render() {
) )
ClearLine() ClearLine()
fmt.Print(line) fmt.Fprint(sink, line)
} }
+113
View File
@@ -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
}
+5 -6
View File
@@ -14,7 +14,6 @@ package output
import ( import (
"fmt" "fmt"
"os"
"sync" "sync"
"time" "time"
) )
@@ -42,7 +41,7 @@ func NewSpinner(message string) *Spinner {
// Start begins the spinner animation // Start begins the spinner animation
func (s *Spinner) Start() { func (s *Spinner) Start() {
if apiMode { if apiMode || silent {
return return
} }
@@ -57,7 +56,7 @@ func (s *Spinner) Start() {
// In non-TTY mode, just print the message once // In non-TTY mode, just print the message once
if !IsTTY { if !IsTTY {
fmt.Printf(" %s...\n", s.message) fmt.Fprintf(sink, " %s...\n", s.message)
return return
} }
@@ -66,7 +65,7 @@ func (s *Spinner) Start() {
// Stop halts the spinner and clears the line // Stop halts the spinner and clears the line
func (s *Spinner) Stop() { func (s *Spinner) Stop() {
if apiMode { if apiMode || silent {
return return
} }
@@ -112,8 +111,8 @@ func (s *Spinner) animate() {
spinnerChar := prefixInfo.Render(spinnerFrames[frame]) spinnerChar := prefixInfo.Render(spinnerFrames[frame])
line := fmt.Sprintf("\r %s %s", spinnerChar, msg) line := fmt.Sprintf("\r %s %s", spinnerChar, msg)
fmt.Fprint(os.Stdout, "\033[2K") // Clear line fmt.Fprint(sink, "\033[2K") // Clear line
fmt.Fprint(os.Stdout, line) fmt.Fprint(sink, line)
frame = (frame + 1) % len(spinnerFrames) frame = (frame + 1) % len(spinnerFrames)
} }
+23 -3
View File
@@ -1,5 +1,5 @@
.\" man page for sif - the blazing-fast pentesting suite .\" 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 .SH NAME
sif \- blazing-fast pentesting suite sif \- blazing-fast pentesting suite
.SH SYNOPSIS .SH SYNOPSIS
@@ -15,17 +15,25 @@ sif \- blazing-fast pentesting suite
.RI [ scans ] .RI [ scans ]
.RI [ options ] .RI [ options ]
.br .br
.I "targets"
|
.B sif
.RI [ scans ]
.RI [ options ]
.br
.B sif .B sif
.RB { patchnote | version } .RB { patchnote | version }
.SH DESCRIPTION .SH DESCRIPTION
.B sif .B sif
is a modular recon and exploitation suite. it runs multiple scan types is a modular recon and exploitation suite. it runs multiple scan types
concurrently against one or more targets, and can be extended with yaml 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:// .B http://
or or
.B https:// .B https://
scheme. is kept; any other scheme is rejected.
.SH TARGETS .SH TARGETS
.TP .TP
.BR \-u ", " \-\-urls " \fIlist\fR" .BR \-u ", " \-\-urls " \fIlist\fR"
@@ -33,6 +41,13 @@ comma\-separated list of urls to scan.
.TP .TP
.BR \-f ", " \-\-file " \fIpath\fR" .BR \-f ", " \-\-file " \fIpath\fR"
file with one url per line. 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 .SH SCANS
.TP .TP
.BR \-dirlist " \fIsize\fR" .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" .BR \-md ", " \-\-markdown " \fIfile\fR"
write a markdown report of the run to \fIfile\fR. write a markdown report of the run to \fIfile\fR.
.TP .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 .B \-api
emit json results and suppress the interactive output. emit json results and suppress the interactive output.
.SH MODULES .SH MODULES
+121 -18
View File
@@ -20,6 +20,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
@@ -85,13 +86,19 @@ func NewModuleResult[T ScanResult](data T) ModuleResult {
func New(settings *config.Settings) (*App, error) { func New(settings *config.Settings) (*App, error) {
app := &App{settings: settings} 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 { if !settings.ApiMode {
fmt.Println(output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ ")) fmt.Fprintln(output.Writer(), output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ "))
tagline := "blazing-fast pentesting suite" tagline := "blazing-fast pentesting suite"
if Version != "dev" { if Version != "dev" {
tagline += " · v" + Version 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 { } else {
output.SetAPIMode(true) output.SetAPIMode(true)
} }
@@ -101,10 +108,11 @@ func New(settings *config.Settings) (*App, error) {
return app, nil return app, nil
} }
switch { // -u and -f are explicit; stdin is additive so `subfinder | sif -u extra`
case len(settings.URLs) > 0: // still works. order: flags first, then piped lines appended.
app.targets = settings.URLs app.targets = append(app.targets, settings.URLs...)
case settings.File != "":
if settings.File != "" {
if _, err := os.Stat(settings.File); err != nil { if _, err := os.Stat(settings.File); err != nil {
return nil, err return nil, err
} }
@@ -120,29 +128,105 @@ func New(settings *config.Settings) (*App, error) {
for scanner.Scan() { for scanner.Scan() {
app.targets = append(app.targets, scanner.Text()) 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 // when stdin is a pipe (not a terminal), drain it for targets so sif slots
for _, url := range app.targets { // into a unix pipeline: `subfinder -d x | sif -silent | notify`. keyed off
if err := validateURL(url); err != nil { // 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 return nil, err
} }
app.targets[i] = normalized
} }
return app, nil return app, nil
} }
// validateURL checks that a URL has a valid HTTP/HTTPS protocol. // defaultScheme is prepended to scheme-less targets. https is the safer default
func validateURL(url string) error { // for recon: it's what modern hosts serve and avoids a cleartext first hop.
if url == "" { const defaultScheme = "https://"
return fmt.Errorf("empty URL provided")
// 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 info.Mode()&os.ModeCharDevice == 0, nil
return fmt.Errorf("URL %s must include http:// or https:// protocol", url) }
// 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 // 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. // 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)) 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 wantReport {
if err := app.writeReports(reportResults); err != nil { if err := app.writeReports(reportResults); err != nil {
return err return err
@@ -575,6 +666,18 @@ func (app *App) Run() error {
return nil 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 // collectFindings normalizes one target's module results through finding.Flatten
// - the single normalization path that notify and diff (later bundles) build on. // - 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 // every scan result struct collapses to flat, severity-ranked findings here so a
+203 -6
View File
@@ -13,11 +13,24 @@
package sif package sif
import ( import (
"io"
"os"
"strings"
"testing" "testing"
"github.com/dropalldatabases/sif/internal/config" "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 // mockResult is a test implementation of ScanResult
type mockResult struct { type mockResult struct {
name string name string
@@ -117,20 +130,16 @@ func TestNew_URLValidation(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
// naked host is now accepted and normalized, not rejected
name: "missing protocol", name: "missing protocol",
url: "example.com", url: "example.com",
wantErr: true, wantErr: false,
}, },
{ {
name: "invalid protocol", name: "invalid protocol",
url: "ftp://example.com", url: "ftp://example.com",
wantErr: true, wantErr: true,
}, },
{
name: "empty url",
url: "",
wantErr: true,
},
} }
for _, tt := range tests { 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) { func TestModuleResult_JSON(t *testing.T) {
mr := ModuleResult{ mr := ModuleResult{
Id: "test", Id: "test",