mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
ef0408ee8d
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.
376 lines
10 KiB
Go
376 lines
10 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
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
|
|
data string
|
|
}
|
|
|
|
func (m *mockResult) ResultType() string {
|
|
return m.name
|
|
}
|
|
|
|
func TestNewModuleResult(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result *mockResult
|
|
wantID string
|
|
}{
|
|
{
|
|
name: "basic result",
|
|
result: &mockResult{name: "test", data: "test data"},
|
|
wantID: "test",
|
|
},
|
|
{
|
|
name: "empty name",
|
|
result: &mockResult{name: "", data: "data"},
|
|
wantID: "",
|
|
},
|
|
{
|
|
name: "complex name",
|
|
result: &mockResult{name: "framework-detection", data: "Laravel 8.0"},
|
|
wantID: "framework-detection",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mr := NewModuleResult(tt.result)
|
|
if mr.Id != tt.wantID {
|
|
t.Errorf("NewModuleResult() Id = %q, want %q", mr.Id, tt.wantID)
|
|
}
|
|
if mr.Data != tt.result {
|
|
t.Errorf("NewModuleResult() Data = %v, want %v", mr.Data, tt.result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNew_NoTargets(t *testing.T) {
|
|
settings := &config.Settings{
|
|
URLs: []string{},
|
|
File: "",
|
|
}
|
|
|
|
_, err := New(settings)
|
|
if err == nil {
|
|
t.Error("New() should return error when no targets provided")
|
|
}
|
|
}
|
|
|
|
func TestNew_WithURLs(t *testing.T) {
|
|
settings := &config.Settings{
|
|
URLs: []string{"https://example.com"},
|
|
ApiMode: true,
|
|
}
|
|
|
|
app, err := New(settings)
|
|
if err != nil {
|
|
t.Fatalf("New() unexpected error: %v", err)
|
|
}
|
|
|
|
if app == nil {
|
|
t.Fatal("New() returned nil app")
|
|
}
|
|
|
|
if len(app.targets) != 1 {
|
|
t.Errorf("New() targets = %d, want 1", len(app.targets))
|
|
}
|
|
|
|
if app.targets[0] != "https://example.com" {
|
|
t.Errorf("New() target = %q, want %q", app.targets[0], "https://example.com")
|
|
}
|
|
}
|
|
|
|
func TestNew_URLValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid https url",
|
|
url: "https://example.com",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid http url",
|
|
url: "http://example.com",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
// naked host is now accepted and normalized, not rejected
|
|
name: "missing protocol",
|
|
url: "example.com",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid protocol",
|
|
url: "ftp://example.com",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
settings := &config.Settings{
|
|
URLs: []string{tt.url},
|
|
ApiMode: true,
|
|
}
|
|
|
|
_, err := New(settings)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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",
|
|
Data: map[string]string{"key": "value"},
|
|
}
|
|
|
|
// Verify the struct can be used (basic sanity check)
|
|
if mr.Id != "test" {
|
|
t.Errorf("ModuleResult.Id = %q, want %q", mr.Id, "test")
|
|
}
|
|
}
|
|
|
|
func TestUrlResult_JSON(t *testing.T) {
|
|
ur := UrlResult{
|
|
Url: "https://example.com",
|
|
Results: []ModuleResult{
|
|
{Id: "test", Data: "data"},
|
|
},
|
|
}
|
|
|
|
if ur.Url != "https://example.com" {
|
|
t.Errorf("UrlResult.Url = %q, want %q", ur.Url, "https://example.com")
|
|
}
|
|
|
|
if len(ur.Results) != 1 {
|
|
t.Errorf("UrlResult.Results = %d, want 1", len(ur.Results))
|
|
}
|
|
}
|