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 |
| `-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:
+3 -1
View File
@@ -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)
}
+25
View File
@@ -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
+2
View File
@@ -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",
+9
View File
@@ -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.
+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 (
"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)
}
+3 -3
View File
@@ -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)
}
+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 (
"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)
}
+23 -3
View File
@@ -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
+121 -18
View File
@@ -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
+203 -6
View File
@@ -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",