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.
This commit is contained in:
vmfunc
2026-06-10 16:38:01 -07:00
parent 6ec0b60e5a
commit 8078978a44
15 changed files with 1161 additions and 0 deletions
+28
View File
@@ -242,6 +242,34 @@ sarif output is ingestable by github code scanning; markdown is a readable per-t
the snapshot is always rewritten, so each run diffs against the previous one. the delta is chrome (it rides the normal output sink / stderr under `-silent`), not the findings stream.
### notify
ship findings to a chat/webhook sink so a continuous-recon run alerts on what it turns up. every provider is a single POST through the shared http client, so the global proxy/rate-limit/header config applies.
| flag | description |
|------|-------------|
| `-notify` | ship findings to every configured provider after the scan |
| `-notify-severity` | minimum severity to send (`info`/`low`/`medium`/`high`/`critical`, default `medium`) |
| `-notify-config` | path to a notify-compatible yaml config (overrides env vars) |
providers are configured env-first; a yaml file (`-notify-config`) overrides per-field. the yaml keys match [projectdiscovery/notify](https://github.com/projectdiscovery/notify) so an existing config ports over:
| env var | yaml key | provider |
|---------|----------|----------|
| `SLACK_WEBHOOK_URL` | `slack_webhook_url` | slack incoming webhook |
| `DISCORD_WEBHOOK_URL` | `discord_webhook_url` | discord webhook |
| `TELEGRAM_BOT_TOKEN` | `telegram_api_key` | telegram bot api (needs chat id too) |
| `TELEGRAM_CHAT_ID` | `telegram_chat_id` | telegram destination chat |
| `NOTIFY_WEBHOOK_URL` | `webhook_url` | generic json webhook (structured findings) |
```bash
# alert slack on medium+ findings discovered during a scan
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
./sif -u https://example.com -cors -xss -notify -notify-severity medium
```
a provider with no destination is skipped; with nothing configured, `-notify` is a silent no-op. slack/discord/telegram receive a fixed-width finding block; the generic webhook receives structured json (`{count, findings[]}`).
### pipe mode
sif reads targets from stdin and accepts naked hosts, so it drops into a unix pipeline. `-silent` routes all banner/spinner/log chrome to stderr and prints one normalized finding per line (`[severity] target module title`) to stdout:
+50
View File
@@ -464,6 +464,56 @@ snapshot directory for `-diff`. precedence when unset: the `-log` dir if one is
./sif -u https://example.com -sh -diff -store ./snapshots
```
## notify options
ship findings to a chat/webhook sink after the scan. every provider is a single POST through the shared http client, so the global proxy/rate-limit/header config applies. with nothing configured, `-notify` is a silent no-op.
### -notify
enable delivery to every configured provider:
```bash
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
./sif -u https://example.com -cors -xss -notify
```
### -notify-severity
minimum severity to send: `info`, `low`, `medium`, `high` or `critical` (default `medium`). findings below the floor are dropped, so info-level recon noise doesn't flood a channel. an unrecognized value falls back to `medium`:
```bash
./sif -u https://example.com -cors -notify -notify-severity high
```
### -notify-config
path to a yaml config that overrides the env vars per-field. the keys match [projectdiscovery/notify](https://github.com/projectdiscovery/notify) so an existing config ports over:
```yaml
slack_webhook_url: https://hooks.slack.com/services/...
discord_webhook_url: https://discord.com/api/webhooks/...
telegram_api_key: 123456:abcdef
telegram_chat_id: "987654"
webhook_url: https://example.internal/sif-findings
```
```bash
./sif -u https://example.com -cors -notify -notify-config notify.yaml
```
providers are resolved env-first, then overlaid by the yaml file:
| env var | yaml key | provider |
|---------|----------|----------|
| `SLACK_WEBHOOK_URL` | `slack_webhook_url` | slack incoming webhook |
| `DISCORD_WEBHOOK_URL` | `discord_webhook_url` | discord webhook |
| `TELEGRAM_BOT_TOKEN` | `telegram_api_key` | telegram bot api (needs chat id too) |
| `TELEGRAM_CHAT_ID` | `telegram_chat_id` | telegram destination chat |
| `NOTIFY_WEBHOOK_URL` | `webhook_url` | generic json webhook (structured findings) |
slack/discord/telegram receive a fixed-width finding block; the generic webhook receives structured json (`{count, findings[]}`) for downstream automation.
## api options
### -api
+13
View File
@@ -79,6 +79,9 @@ type Settings struct {
Header goflags.StringSlice // custom request headers ("Key: Value")
Cookie string
RateLimit int
Notify bool // -notify: ship findings to configured providers
NotifySeverity string // -notify-severity: minimum severity to send (info..critical)
NotifyConfig string // -notify-config: path to a notify-compatible yaml file
}
// minThreads is the floor for the worker count. Threads feeds wg.Add across the
@@ -90,6 +93,10 @@ const minThreads = 1
// to find linked pages without crawling an entire site.
const defaultCrawlDepth = 2
// defaultNotifySeverity is the floor notify sends at when -notify-severity is
// unset: medium drops pure recon/info noise so alerts stay actionable.
const defaultNotifySeverity = "medium"
const (
Nil goflags.EnumVariable = iota
@@ -180,6 +187,12 @@ func Parse() *Settings {
flagSet.StringVar(&settings.Store, "store", "", "Snapshot directory for -diff (default: log dir, else <user-config>/sif/state)"),
)
flagSet.CreateGroup("notify", "Notify",
flagSet.BoolVar(&settings.Notify, "notify", false, "Ship findings to configured providers (slack/discord/telegram/webhook)"),
flagSet.StringVar(&settings.NotifySeverity, "notify-severity", defaultNotifySeverity, "Minimum severity to notify on (info/low/medium/high/critical)"),
flagSet.StringVar(&settings.NotifyConfig, "notify-config", "", "Path to a notify-compatible yaml config (overrides env vars)"),
)
flagSet.CreateGroup("api", "API",
flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal lunchcat usage"),
)
+119
View File
@@ -0,0 +1,119 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// env var names notify reads, env-first. these mirror the conventional names so
// an operator who already exports them for other tooling gets notify for free.
const (
envSlackWebhook = "SLACK_WEBHOOK_URL"
envDiscordWebhook = "DISCORD_WEBHOOK_URL"
// the name of the env var holding the bot token, not the token itself.
envTelegramToken = "TELEGRAM_BOT_TOKEN" //nolint:gosec // env var name, not a secret
envTelegramChat = "TELEGRAM_CHAT_ID"
envWebhookURL = "NOTIFY_WEBHOOK_URL"
)
// config holds resolved destinations for every provider. yaml tags use
// projectdiscovery/notify-compatible key names so an existing notify config file
// ports over verbatim; env supplies the same values and yaml overrides it.
type config struct {
SlackWebhook string `yaml:"slack_webhook_url"`
DiscordWebhook string `yaml:"discord_webhook_url"`
// telegram needs both a bot token and a chat id. notify spells the token
// "telegram_api_key", so accept that key for drop-in compatibility.
TelegramToken string `yaml:"telegram_api_key"`
TelegramChat string `yaml:"telegram_chat_id"`
WebhookURL string `yaml:"webhook_url"`
}
// loadConfig resolves notify destinations env-first, then overlays a yaml file
// when path is non-empty. yaml wins per-field so a file value overrides the
// matching env var; an unset yaml field leaves the env value intact. an empty
// path means env-only. a missing/unparseable file is an error - if the operator
// pointed -notify-config somewhere, a typo should fail loud, not silently drop.
func loadConfig(path string) (config, error) {
cfg := config{
SlackWebhook: os.Getenv(envSlackWebhook),
DiscordWebhook: os.Getenv(envDiscordWebhook),
TelegramToken: os.Getenv(envTelegramToken),
TelegramChat: os.Getenv(envTelegramChat),
WebhookURL: os.Getenv(envWebhookURL),
}
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return config{}, fmt.Errorf("read config %q: %w", path, err)
}
// decode into a separate value so only the keys present in the file overlay
// the env-derived defaults; a zero field in the yaml must not blank an env var.
var file config
if err := yaml.Unmarshal(data, &file); err != nil {
return config{}, fmt.Errorf("parse config %q: %w", path, err)
}
overlay(&cfg, &file)
return cfg, nil
}
// overlay copies non-empty fields from src onto dst. used to let a yaml file
// override env without an empty yaml key wiping out a populated env value.
func overlay(dst, src *config) {
if src.SlackWebhook != "" {
dst.SlackWebhook = src.SlackWebhook
}
if src.DiscordWebhook != "" {
dst.DiscordWebhook = src.DiscordWebhook
}
if src.TelegramToken != "" {
dst.TelegramToken = src.TelegramToken
}
if src.TelegramChat != "" {
dst.TelegramChat = src.TelegramChat
}
if src.WebhookURL != "" {
dst.WebhookURL = src.WebhookURL
}
}
// providers builds the live provider list from the resolved config: a provider
// is included only when its destination is fully specified. telegram needs both
// token and chat id, so a half-configured telegram is dropped rather than POSTing
// to a broken endpoint.
func (c *config) providers() []provider {
var out []provider
if c.SlackWebhook != "" {
out = append(out, &slackProvider{webhook: c.SlackWebhook})
}
if c.DiscordWebhook != "" {
out = append(out, &discordProvider{webhook: c.DiscordWebhook})
}
if c.TelegramToken != "" && c.TelegramChat != "" {
out = append(out, &telegramProvider{token: c.TelegramToken, chatID: c.TelegramChat})
}
if c.WebhookURL != "" {
out = append(out, &webhookProvider{url: c.WebhookURL})
}
return out
}
+153
View File
@@ -0,0 +1,153 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"os"
"path/filepath"
"testing"
)
// clearNotifyEnv unsets every var loadConfig reads so a test starts from a known
// blank slate; t.Setenv("", "") still records the var for cleanup restoration.
func clearNotifyEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
envSlackWebhook, envDiscordWebhook,
envTelegramToken, envTelegramChat, envWebhookURL,
} {
t.Setenv(k, "")
}
}
func TestLoadConfigEnvOnly(t *testing.T) {
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, "https://hooks.slack.test/a")
t.Setenv(envTelegramToken, "123:abc")
t.Setenv(envTelegramChat, "999")
cfg, err := loadConfig("")
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.SlackWebhook != "https://hooks.slack.test/a" {
t.Errorf("slack webhook = %q, want from env", cfg.SlackWebhook)
}
if cfg.TelegramToken != "123:abc" || cfg.TelegramChat != "999" {
t.Errorf("telegram = %q/%q, want from env", cfg.TelegramToken, cfg.TelegramChat)
}
// slack + telegram (both halves) configured, discord/webhook empty.
got := cfg.providers()
if len(got) != 2 {
t.Fatalf("providers = %d, want 2 (slack, telegram)", len(got))
}
wantNames := map[string]bool{"slack": false, "telegram": false}
for _, p := range got {
wantNames[p.name()] = true
}
for name, seen := range wantNames {
if !seen {
t.Errorf("provider %q missing", name)
}
}
}
func TestLoadConfigYAMLOverridesEnv(t *testing.T) {
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, "https://env.slack.test/x")
t.Setenv(envWebhookURL, "https://env.webhook.test/x")
body := "" +
"slack_webhook_url: https://file.slack.test/y\n" +
"discord_webhook_url: https://file.discord.test/z\n"
path := writeTempConfig(t, body)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
// yaml present -> overrides env.
if cfg.SlackWebhook != "https://file.slack.test/y" {
t.Errorf("slack = %q, want yaml override", cfg.SlackWebhook)
}
// yaml absent for webhook -> env value survives.
if cfg.WebhookURL != "https://env.webhook.test/x" {
t.Errorf("webhook = %q, want env value preserved", cfg.WebhookURL)
}
// yaml introduces discord.
if cfg.DiscordWebhook != "https://file.discord.test/z" {
t.Errorf("discord = %q, want from yaml", cfg.DiscordWebhook)
}
}
func TestLoadConfigNotifyCompatibleTelegramKey(t *testing.T) {
clearNotifyEnv(t)
// projectdiscovery/notify spells the bot token "telegram_api_key"; assert a
// drop-in config wires telegram from that key.
body := "" +
"telegram_api_key: 555:tok\n" +
"telegram_chat_id: \"42\"\n"
path := writeTempConfig(t, body)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.TelegramToken != "555:tok" || cfg.TelegramChat != "42" {
t.Fatalf("telegram = %q/%q, want from notify-compatible keys", cfg.TelegramToken, cfg.TelegramChat)
}
if len(cfg.providers()) != 1 {
t.Fatalf("providers = %d, want 1 (telegram)", len(cfg.providers()))
}
}
func TestLoadConfigMissingFileErrors(t *testing.T) {
clearNotifyEnv(t)
if _, err := loadConfig(filepath.Join(t.TempDir(), "nope.yaml")); err == nil {
t.Fatal("loadConfig with missing file: want error, got nil")
}
}
func TestLoadConfigBadYAMLErrors(t *testing.T) {
clearNotifyEnv(t)
path := writeTempConfig(t, "slack_webhook_url: [unterminated\n")
if _, err := loadConfig(path); err == nil {
t.Fatal("loadConfig with malformed yaml: want error, got nil")
}
}
func TestProvidersTelegramNeedsBothHalves(t *testing.T) {
// token without chat id must not produce a (broken) telegram provider.
cfg := config{TelegramToken: "tok"}
if got := cfg.providers(); len(got) != 0 {
t.Fatalf("providers = %d, want 0 for half-configured telegram", len(got))
}
}
func TestProvidersEmptyConfigIsNone(t *testing.T) {
var cfg config
if got := cfg.providers(); len(got) != 0 {
t.Fatalf("providers = %d, want 0 for empty config", len(got))
}
}
// writeTempConfig writes body to a temp yaml file and returns its path.
func writeTempConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "notify.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write temp config: %v", err)
}
return path
}
+39
View File
@@ -0,0 +1,39 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// discordProvider posts to a discord webhook. discord's incoming-webhook body
// keys the message on "content" (slack uses "text"); same code-block wrapping so
// the finding columns line up in the channel.
type discordProvider struct {
webhook string
}
func (d *discordProvider) name() string { return "discord" }
// discordPayload is the minimal webhook body: a single content field.
type discordPayload struct {
Content string `json:"content"`
}
func (d *discordProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
payload := discordPayload{Content: codeBlock(renderFindings(findings))}
return postJSON(ctx, client, d.webhook, payload)
}
+74
View File
@@ -0,0 +1,74 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/httpx"
)
// contentTypeJSON is the body type every provider POSTs; all four speak json.
const contentTypeJSON = "application/json"
// messageHeader prefixes the rendered finding block. kept terse - chat sinks
// truncate, so the count and lead-in carry the signal.
const messageHeader = "sif found %d finding(s):"
// renderFindings turns a batch into a single plain-text block, one finding per
// line in the same "[severity] target module title" shape as the -silent sink so
// a reader sees identical lines across stdout and chat. a strings.Builder keeps
// the per-line concat to one allocation path.
func renderFindings(findings []finding.Finding) string {
var b strings.Builder
fmt.Fprintf(&b, messageHeader, len(findings))
b.WriteByte('\n')
for i := 0; i < len(findings); i++ {
b.WriteString(findings[i].Line())
b.WriteByte('\n')
}
return b.String()
}
// postJSON marshals payload and POSTs it to url through the shared client. it
// drains+closes the response so the conn returns to httpx's pool, and treats any
// non-2xx as a delivery failure so a 4xx from a bad webhook surfaces loudly.
func postJSON(ctx context.Context, client *http.Client, url string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", contentTypeJSON)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return fmt.Errorf("post: %w", err)
}
defer httpx.DrainClose(resp)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package notify ships findings to chat/webhook sinks (slack, discord, telegram,
// generic webhook) so a continuous-recon run can alert on what it turns up. every
// provider is one POST through httpx.Client, so the global proxy/rate-limit/header
// config applies uniformly and there's no extra http stack to keep in sync.
package notify
import (
"context"
"fmt"
"net/http"
"time"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/output"
)
// Options carries the runtime knobs Send needs. Timeout bounds each provider's
// POST; ConfigPath is an optional yaml file whose values override env. severity
// filtering is the caller's job - Send ships whatever batch it's handed.
type Options struct {
Timeout time.Duration
ConfigPath string
}
// Send dispatches findings to every configured provider. config resolves
// env-first, then a yaml file overlays it (notify-compatible key names). a
// provider with no destination is skipped, so zero configured providers makes
// Send a silent no-op - notify is opt-in and never errors just for being unwired.
// an empty findings slice is also a no-op: nothing to report.
func Send(ctx context.Context, findings []finding.Finding, opts Options) error {
if len(findings) == 0 {
return nil
}
cfg, err := loadConfig(opts.ConfigPath)
if err != nil {
return fmt.Errorf("notify config: %w", err)
}
providers := cfg.providers()
if len(providers) == 0 {
// nothing wired up; opt-in feature stays quiet rather than erroring.
return nil
}
log := output.Module("NOTIFY")
client := httpx.Client(opts.Timeout)
// run every provider; a failure on one sink must not suppress the others, so
// errors accumulate and the first is returned after all have been attempted.
var firstErr error
for i := 0; i < len(providers); i++ {
p := providers[i]
if err := p.send(ctx, client, findings); err != nil {
log.Error("%s delivery failed: %v", p.name(), err)
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", p.name(), err)
}
continue
}
log.Success("sent %d findings to %s", len(findings), p.name())
}
return firstErr
}
// provider is one delivery sink. name is for logging; send formats findings into
// the sink's payload and POSTs it through the shared client.
type provider interface {
name() string
send(ctx context.Context, client *http.Client, findings []finding.Finding) error
}
+224
View File
@@ -0,0 +1,224 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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)
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// slackProvider posts to a slack incoming webhook. the webhook url already pins
// the channel, so the payload is just the rendered text in slack's mrkdwn-aware
// "text" field wrapped in a code block to keep the fixed-width finding lines.
type slackProvider struct {
webhook string
}
func (s *slackProvider) name() string { return "slack" }
// slackPayload is the minimal incoming-webhook body: a single text field.
type slackPayload struct {
Text string `json:"text"`
}
func (s *slackProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
payload := slackPayload{Text: codeBlock(renderFindings(findings))}
return postJSON(ctx, client, s.webhook, payload)
}
// codeBlock wraps body in a triple-backtick fence; both slack and discord render
// it fixed-width, which preserves the column-aligned finding lines.
func codeBlock(body string) string {
return "```\n" + body + "```"
}
+48
View File
@@ -0,0 +1,48 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// telegramAPIBase is the bot api root. it's a var so tests can repoint it at an
// httptest server; the token is appended path-side per telegram's scheme.
var telegramAPIBase = "https://api.telegram.org"
// telegramProvider posts via the bot api's sendMessage. unlike slack/discord the
// destination isn't a single opaque webhook: it needs the bot token (in the url
// path) plus the chat id (in the body).
type telegramProvider struct {
token string
chatID string
}
func (t *telegramProvider) name() string { return "telegram" }
// telegramPayload is the sendMessage body. parse_mode "MarkdownV2" would force
// escaping every special char in the finding lines, so we send plain text and
// let the lines stand as-is.
type telegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
func (t *telegramProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
endpoint := telegramAPIBase + "/bot" + t.token + "/sendMessage"
payload := telegramPayload{ChatID: t.chatID, Text: renderFindings(findings)}
return postJSON(ctx, client, endpoint, payload)
}
+65
View File
@@ -0,0 +1,65 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// webhookProvider posts a structured json payload to an arbitrary endpoint. unlike
// the chat sinks it carries the findings as data, not a prerendered blob, so
// downstream automation (a siem, a bot, ci) keys off the fields directly.
type webhookProvider struct {
url string
}
func (w *webhookProvider) name() string { return "webhook" }
// webhookFinding is the per-item wire shape: the normalized Finding fields with
// severity flattened to its canonical string so a json consumer never sees the
// internal integer rank.
type webhookFinding struct {
Target string `json:"target"`
Module string `json:"module"`
Severity string `json:"severity"`
Key string `json:"key"`
Title string `json:"title"`
Raw string `json:"raw,omitempty"`
}
// webhookPayload wraps the batch with a count so a consumer can size buffers /
// assert completeness without walking the slice first.
type webhookPayload struct {
Count int `json:"count"`
Findings []webhookFinding `json:"findings"`
}
func (w *webhookProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
items := make([]webhookFinding, 0, len(findings))
for i := 0; i < len(findings); i++ {
f := findings[i]
items = append(items, webhookFinding{
Target: f.Target,
Module: f.Module,
Severity: f.Severity.String(),
Key: f.Key,
Title: f.Title,
Raw: f.Raw,
})
}
payload := webhookPayload{Count: len(items), Findings: items}
return postJSON(ctx, client, w.url, payload)
}
+27
View File
@@ -209,6 +209,17 @@ the first run for a target reports everything as new.
.BR \-store " \fIdir\fR"
snapshot directory for \fB\-diff\fR. defaults to the \fB\-log\fR dir if set,
otherwise \fI<user\-config>/sif/state\fR. one sanitized file per target.
.B \-notify
ship findings to every configured provider (slack, discord, telegram, generic
webhook) after the scan. providers are configured env\-first and overridable by a
yaml file; with nothing configured this is a silent no\-op.
.TP
.BR \-notify\-severity " \fIlevel\fR"
minimum severity to send: \fBinfo\fR, \fBlow\fR, \fBmedium\fR, \fBhigh\fR or
\fBcritical\fR (default \fBmedium\fR). findings below the floor are dropped.
.TP
.BR \-notify\-config " \fIfile\fR"
path to a notify\-compatible yaml config whose values override the env vars.
.TP
.B \-api
emit json results and suppress the interactive output.
@@ -241,6 +252,22 @@ api key used by \fB\-shodan\fR.
.B SECURITYTRAILS_API_KEY
api key used by \fB\-securitytrails\fR.
.TP
.B SLACK_WEBHOOK_URL
slack incoming webhook used by \fB\-notify\fR (yaml key \fBslack_webhook_url\fR).
.TP
.B DISCORD_WEBHOOK_URL
discord webhook used by \fB\-notify\fR (yaml key \fBdiscord_webhook_url\fR).
.TP
.B TELEGRAM_BOT_TOKEN
telegram bot token used by \fB\-notify\fR (yaml key \fBtelegram_api_key\fR);
requires \fBTELEGRAM_CHAT_ID\fR too.
.TP
.B TELEGRAM_CHAT_ID
telegram destination chat used by \fB\-notify\fR (yaml key \fBtelegram_chat_id\fR).
.TP
.B NOTIFY_WEBHOOK_URL
generic json webhook used by \fB\-notify\fR (yaml key \fBwebhook_url\fR).
.TP
.B SIF_NO_PATCHNOTES
set to any value to suppress the once\-per\-version patch note shown at startup.
.SH FILES
+157
View File
@@ -0,0 +1,157 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
}
+34
View File
@@ -31,6 +31,7 @@ import (
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/notify"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/report"
"github.com/dropalldatabases/sif/internal/scan"
@@ -699,6 +700,14 @@ func (app *App) Run() error {
// count now so the path is live and observable without changing output.
log.Debugf("normalized %d findings across %d targets", len(allFindings), len(app.targets))
// notify: ship the severity-filtered findings to any configured provider.
// kept as an isolated block so it merges cleanly with the diff-store bundle.
if app.settings.Notify {
if err := app.notifyFindings(context.Background(), allFindings); err != nil {
log.Errorf("notify: %v", err)
}
}
// -silent: stdout is the findings stream, one terse line each. all chrome
// already went to stderr via the rerouted sink, so this is the only thing a
// downstream pipe sees.
@@ -845,6 +854,31 @@ func (app *App) writeReports(results []report.Result) error {
return nil
}
// notifyFindings filters the run's findings to the -notify-severity floor and
// ships the survivors to every configured provider. an unrecognized severity
// string parses to SeverityUnknown, which would let everything through; guard
// against that by defaulting to medium so a typo can't flood a channel with
// info noise. an empty filtered set makes notify.Send a no-op.
func (app *App) notifyFindings(ctx context.Context, findings []finding.Finding) error {
floor := finding.ParseSeverity(app.settings.NotifySeverity)
if floor == finding.SeverityUnknown {
log.Warnf("notify: unknown severity %q, defaulting to medium", app.settings.NotifySeverity)
floor = finding.SeverityMedium
}
filtered := make([]finding.Finding, 0, len(findings))
for i := 0; i < len(findings); i++ {
if findings[i].Severity.AtLeast(floor) {
filtered = append(filtered, findings[i])
}
}
return notify.Send(ctx, filtered, notify.Options{
Timeout: app.settings.Timeout,
ConfigPath: app.settings.NotifyConfig,
})
}
// expandTargets queries SecurityTrails for each original target and returns
// newly discovered domains (subdomains + associated) for target expansion
func (app *App) expandTargets() []string {