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 |
|
| `-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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 (
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user