mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
6ec0b60e5a
re-scans become a monitor: -diff snapshots each target's normalized findings to a per-target json file and, on the next run, surfaces only the delta (+ new / - gone) against the last snapshot, then overwrites it so each run diffs against the previous one. behavior is unchanged when -diff is off. new internal/store keys the set-difference off finding.Key (already stable across runs) and uses only encoding/json + os - no new deps. snapshot files are sanitized per target (no traversal), written 0600 under 0750 dirs. -store picks the location: explicit dir, else the log dir, else <user-config>/sif/state. a missing snapshot is a clean baseline, a corrupt one self-heals on the next save.
446 lines
13 KiB
Go
446 lines
13 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"
|
|
"github.com/dropalldatabases/sif/internal/store"
|
|
)
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
func TestResolveStoreDir(t *testing.T) {
|
|
// explicit -store wins over everything.
|
|
explicit := &App{settings: &config.Settings{Store: "/tmp/snaps", LogDir: "/tmp/logs"}}
|
|
if dir, err := explicit.resolveStoreDir(); err != nil || dir != "/tmp/snaps" {
|
|
t.Fatalf("explicit store: got (%q, %v), want (/tmp/snaps, nil)", dir, err)
|
|
}
|
|
|
|
// no -store: reuse the log dir.
|
|
logged := &App{settings: &config.Settings{LogDir: "/tmp/logs"}}
|
|
if dir, err := logged.resolveStoreDir(); err != nil || dir != "/tmp/logs" {
|
|
t.Fatalf("log dir fallback: got (%q, %v), want (/tmp/logs, nil)", dir, err)
|
|
}
|
|
|
|
// neither set: fall through to the per-user default (non-empty, no error).
|
|
bare := &App{settings: &config.Settings{}}
|
|
dir, err := bare.resolveStoreDir()
|
|
if err != nil {
|
|
t.Fatalf("default store dir: %v", err)
|
|
}
|
|
if dir == "" {
|
|
t.Fatal("default store dir resolved empty")
|
|
}
|
|
}
|
|
|
|
func TestDiffTargetSnapshotsAndDiffs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
const target = "https://diff.example.com"
|
|
app := &App{settings: &config.Settings{Diff: true, Store: dir}}
|
|
|
|
first := []finding.Finding{
|
|
{Target: target, Module: "headers", Severity: finding.SeverityInfo, Key: "headers:Server", Title: "Server", Raw: "nginx"},
|
|
}
|
|
|
|
// first run: no prior snapshot, everything is new; the snapshot must persist.
|
|
app.diffTarget(dir, target, first)
|
|
|
|
saved, err := store.Load(dir, target)
|
|
if err != nil {
|
|
t.Fatalf("load after first run: %v", err)
|
|
}
|
|
if len(saved) != 1 || saved[0].Key != "headers:Server" {
|
|
t.Fatalf("snapshot after first run = %#v, want the headers finding", saved)
|
|
}
|
|
|
|
// second run with a different set: the snapshot must advance to the new set so
|
|
// a third run would diff against it.
|
|
second := []finding.Finding{
|
|
{Target: target, Module: "cors", Severity: finding.SeverityMedium, Key: "cors:x", Title: "null origin", Raw: "null"},
|
|
}
|
|
app.diffTarget(dir, target, second)
|
|
|
|
saved, err = store.Load(dir, target)
|
|
if err != nil {
|
|
t.Fatalf("load after second run: %v", err)
|
|
}
|
|
if len(saved) != 1 || saved[0].Key != "cors:x" {
|
|
t.Fatalf("snapshot after second run = %#v, want the cors finding", saved)
|
|
}
|
|
|
|
// the delta between the two snapshots is exactly: headers gone, cors new.
|
|
added, removed := store.Diff(first, second)
|
|
if len(added) != 1 || added[0].Key != "cors:x" {
|
|
t.Fatalf("added = %#v, want cors:x", added)
|
|
}
|
|
if len(removed) != 1 || removed[0].Key != "headers:Server" {
|
|
t.Fatalf("removed = %#v, want headers:Server", removed)
|
|
}
|
|
}
|