mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -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.
225 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|