Files
sif/notify_test.go
vmfunc 8078978a44 feat: notify integrations (slack, discord, telegram, webhook)
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.
2026-06-10 16:40:14 -07:00

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
}