mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user