Files
sif/internal/notify/notify_test.go
T
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

225 lines
7.5 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/finding"
)
// sampleFindings returns a small mixed-severity batch for payload assertions.
func sampleFindings() []finding.Finding {
return []finding.Finding{
{Target: "https://a.test", Module: "cors", Severity: finding.SeverityHigh, Key: "cors:a", Title: "reflected origin", Raw: "ACAO echo"},
{Target: "https://a.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:x", Title: "Server header", Raw: "nginx"},
}
}
// capture records the method, content-type and raw body of the request a provider
// makes, so each test can assert the wire shape without a real network.
type capture struct {
method string
contentType string
path string
body []byte
}
// captureServer stands up an httptest server that records the single inbound
// request into c and replies 200, the happy path every provider expects.
func captureServer(t *testing.T, c *capture) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
c.method = r.Method
c.contentType = r.Header.Get("Content-Type")
c.path = r.URL.Path
c.body = body
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv
}
func TestSlackPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &slackProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("slack send: %v", err)
}
assertPostJSON(t, c)
var payload slackPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal slack body: %v", err)
}
// slack keys on "text"; both findings must appear, code-block fenced.
if !strings.Contains(payload.Text, "reflected origin") || !strings.Contains(payload.Text, "Server header") {
t.Errorf("slack text missing findings: %q", payload.Text)
}
if !strings.HasPrefix(payload.Text, "```") {
t.Errorf("slack text not code-block fenced: %q", payload.Text)
}
}
func TestDiscordPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &discordProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("discord send: %v", err)
}
assertPostJSON(t, c)
var payload discordPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal discord body: %v", err)
}
// discord keys on "content", not "text".
if !strings.Contains(payload.Content, "reflected origin") {
t.Errorf("discord content missing finding: %q", payload.Content)
}
}
func TestTelegramPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
// repoint the bot api base at the test server for the lifetime of this test.
orig := telegramAPIBase
telegramAPIBase = srv.URL
t.Cleanup(func() { telegramAPIBase = orig })
p := &telegramProvider{token: "555:tok", chatID: "42"}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("telegram send: %v", err)
}
assertPostJSON(t, c)
// the token rides the path and the method is sendMessage.
if c.path != "/bot555:tok/sendMessage" {
t.Errorf("telegram path = %q, want /bot555:tok/sendMessage", c.path)
}
var payload telegramPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal telegram body: %v", err)
}
if payload.ChatID != "42" {
t.Errorf("telegram chat_id = %q, want 42", payload.ChatID)
}
if !strings.Contains(payload.Text, "reflected origin") {
t.Errorf("telegram text missing finding: %q", payload.Text)
}
}
func TestWebhookPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &webhookProvider{url: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("webhook send: %v", err)
}
assertPostJSON(t, c)
var payload webhookPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal webhook body: %v", err)
}
// generic webhook carries structured findings, not a prerendered blob.
if payload.Count != 2 || len(payload.Findings) != 2 {
t.Fatalf("webhook count = %d / %d findings, want 2", payload.Count, len(payload.Findings))
}
first := payload.Findings[0]
if first.Severity != "high" {
t.Errorf("webhook severity = %q, want canonical string \"high\"", first.Severity)
}
if first.Key != "cors:a" || first.Module != "cors" {
t.Errorf("webhook finding fields wrong: %+v", first)
}
}
func TestProviderNon2xxIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
p := &slackProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err == nil {
t.Fatal("send to 403 endpoint: want error, got nil")
}
}
func TestSendNoProviderIsNoop(t *testing.T) {
clearNotifyEnv(t)
// no env, no config file -> zero providers -> Send must not error.
if err := Send(context.Background(), sampleFindings(), Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send with no provider: want nil, got %v", err)
}
}
func TestSendEmptyFindingsIsNoop(t *testing.T) {
// even with a provider configured, an empty batch must not POST anything.
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, srv.URL)
if err := Send(context.Background(), nil, Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send with empty findings: want nil, got %v", err)
}
if hit {
t.Fatal("Send with empty findings posted to provider, want no-op")
}
}
func TestSendDeliversToConfiguredProvider(t *testing.T) {
var c capture
srv := captureServer(t, &c)
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, srv.URL)
if err := Send(context.Background(), sampleFindings(), Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send: %v", err)
}
if c.method != http.MethodPost {
t.Fatalf("provider not hit (method=%q)", c.method)
}
}
// assertPostJSON checks the request was a json POST.
func assertPostJSON(t *testing.T, c capture) {
t.Helper()
if c.method != http.MethodPost {
t.Errorf("method = %q, want POST", c.method)
}
if c.contentType != contentTypeJSON {
t.Errorf("content-type = %q, want %q", c.contentType, contentTypeJSON)
}
}