mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
8078978a44
ship findings to chat/webhook sinks after a scan so continuous recon can alert on what it turns up. each provider is one POST through httpx.Client, so the global proxy/rate-limit/header config applies and there's no extra http stack. config resolves env-first (SLACK_WEBHOOK_URL, DISCORD_WEBHOOK_URL, TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, NOTIFY_WEBHOOK_URL), overridable by a notify-compatible yaml file so existing projectdiscovery/notify configs port over. -notify enables it, -notify-severity gates on the finding severity ladder (default medium), -notify-config points at the yaml. wired after the scan loop on the severity-filtered finding set; no provider configured is a silent no-op.
158 lines
5.8 KiB
Go
158 lines
5.8 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package sif
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dropalldatabases/sif/internal/config"
|
|
"github.com/dropalldatabases/sif/internal/finding"
|
|
)
|
|
|
|
// notifyWebhookBody is the generic-webhook wire shape, mirrored here so the
|
|
// wiring test can assert which findings crossed the severity floor without
|
|
// reaching into the notify package internals.
|
|
type notifyWebhookBody struct {
|
|
Count int `json:"count"`
|
|
Findings []struct {
|
|
Severity string `json:"severity"`
|
|
Key string `json:"key"`
|
|
} `json:"findings"`
|
|
}
|
|
|
|
// mixedSeverityFindings spans the whole ladder so a floor test has something to
|
|
// drop on either side of every threshold.
|
|
func mixedSeverityFindings() []finding.Finding {
|
|
return []finding.Finding{
|
|
{Target: "https://t.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:s", Title: "server"},
|
|
{Target: "https://t.test", Module: "redirect", Severity: finding.SeverityLow, Key: "redirect:r", Title: "open redirect"},
|
|
{Target: "https://t.test", Module: "sql", Severity: finding.SeverityMedium, Key: "sql:e", Title: "db error"},
|
|
{Target: "https://t.test", Module: "cors", Severity: finding.SeverityHigh, Key: "cors:c", Title: "reflected origin"},
|
|
{Target: "https://t.test", Module: "lfi", Severity: finding.SeverityCritical, Key: "lfi:l", Title: "path traversal"},
|
|
}
|
|
}
|
|
|
|
func TestNotifyFindingsSeverityFilter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
floor string
|
|
wantKeys []string
|
|
}{
|
|
{name: "medium drops info+low", floor: "medium", wantKeys: []string{"sql:e", "cors:c", "lfi:l"}},
|
|
{name: "high drops everything below", floor: "high", wantKeys: []string{"cors:c", "lfi:l"}},
|
|
{name: "info keeps all", floor: "info", wantKeys: []string{"headers:s", "redirect:r", "sql:e", "cors:c", "lfi:l"}},
|
|
{name: "critical keeps only critical", floor: "critical", wantKeys: []string{"lfi:l"}},
|
|
// an unrecognized floor must default to medium, not let info through.
|
|
{name: "garbage floor defaults medium", floor: "bogus", wantKeys: []string{"sql:e", "cors:c", "lfi:l"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got notifyWebhookBody
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Errorf("unmarshal webhook body: %v", err)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
// route notify at the test server via the generic webhook env var.
|
|
t.Setenv("SLACK_WEBHOOK_URL", "")
|
|
t.Setenv("DISCORD_WEBHOOK_URL", "")
|
|
t.Setenv("TELEGRAM_BOT_TOKEN", "")
|
|
t.Setenv("TELEGRAM_CHAT_ID", "")
|
|
t.Setenv("NOTIFY_WEBHOOK_URL", srv.URL)
|
|
|
|
app := &App{settings: &config.Settings{
|
|
Notify: true,
|
|
NotifySeverity: tt.floor,
|
|
Timeout: time.Second,
|
|
}}
|
|
if err := app.notifyFindings(context.Background(), mixedSeverityFindings()); err != nil {
|
|
t.Fatalf("notifyFindings: %v", err)
|
|
}
|
|
|
|
gotKeys := make([]string, 0, len(got.Findings))
|
|
for _, f := range got.Findings {
|
|
gotKeys = append(gotKeys, f.Key)
|
|
}
|
|
if !equalStringSets(gotKeys, tt.wantKeys) {
|
|
t.Errorf("floor %q delivered keys %v, want %v", tt.floor, gotKeys, tt.wantKeys)
|
|
}
|
|
if got.Count != len(tt.wantKeys) {
|
|
t.Errorf("floor %q count = %d, want %d", tt.floor, got.Count, len(tt.wantKeys))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotifyFindingsBelowFloorIsNoop(t *testing.T) {
|
|
// every finding below the floor -> nothing crosses -> no POST at all.
|
|
hit := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hit = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
t.Setenv("SLACK_WEBHOOK_URL", "")
|
|
t.Setenv("DISCORD_WEBHOOK_URL", "")
|
|
t.Setenv("TELEGRAM_BOT_TOKEN", "")
|
|
t.Setenv("TELEGRAM_CHAT_ID", "")
|
|
t.Setenv("NOTIFY_WEBHOOK_URL", srv.URL)
|
|
|
|
app := &App{settings: &config.Settings{
|
|
Notify: true,
|
|
NotifySeverity: "critical",
|
|
Timeout: time.Second,
|
|
}}
|
|
infoOnly := []finding.Finding{
|
|
{Target: "https://t.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:s", Title: "server"},
|
|
}
|
|
if err := app.notifyFindings(context.Background(), infoOnly); err != nil {
|
|
t.Fatalf("notifyFindings: %v", err)
|
|
}
|
|
if hit {
|
|
t.Fatal("notifyFindings posted with everything below floor, want no-op")
|
|
}
|
|
}
|
|
|
|
// equalStringSets reports whether a and b contain the same elements regardless
|
|
// of order; the wire order mirrors input order, but order isn't the contract.
|
|
func equalStringSets(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
seen := make(map[string]int, len(a))
|
|
for _, s := range a {
|
|
seen[s]++
|
|
}
|
|
for _, s := range b {
|
|
seen[s]--
|
|
}
|
|
for _, n := range seen {
|
|
if n != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|