diff --git a/README.md b/README.md index 7aef3a6..74293fb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/usage.md b/docs/usage.md index 2416eba..6a7499d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 287e6d7..d6ebc77 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 /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"), ) diff --git a/internal/notify/config.go b/internal/notify/config.go new file mode 100644 index 0000000..dbf931f --- /dev/null +++ b/internal/notify/config.go @@ -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 +} diff --git a/internal/notify/config_test.go b/internal/notify/config_test.go new file mode 100644 index 0000000..0f3ae3c --- /dev/null +++ b/internal/notify/config_test.go @@ -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 +} diff --git a/internal/notify/discord.go b/internal/notify/discord.go new file mode 100644 index 0000000..2908a49 --- /dev/null +++ b/internal/notify/discord.go @@ -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) +} diff --git a/internal/notify/message.go b/internal/notify/message.go new file mode 100644 index 0000000..34445b0 --- /dev/null +++ b/internal/notify/message.go @@ -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 +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..bfbae66 --- /dev/null +++ b/internal/notify/notify.go @@ -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 +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..6d2d55b --- /dev/null +++ b/internal/notify/notify_test.go @@ -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) + } +} diff --git a/internal/notify/slack.go b/internal/notify/slack.go new file mode 100644 index 0000000..0caf330 --- /dev/null +++ b/internal/notify/slack.go @@ -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 + "```" +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go new file mode 100644 index 0000000..dbaef18 --- /dev/null +++ b/internal/notify/telegram.go @@ -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) +} diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go new file mode 100644 index 0000000..3ef1d56 --- /dev/null +++ b/internal/notify/webhook.go @@ -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) +} diff --git a/man/sif.1 b/man/sif.1 index 5c52fcf..b364fa8 100644 --- a/man/sif.1 +++ b/man/sif.1 @@ -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/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 diff --git a/notify_test.go b/notify_test.go new file mode 100644 index 0000000..c8f111d --- /dev/null +++ b/notify_test.go @@ -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 +} diff --git a/sif.go b/sif.go index faec3ba..acc2aa2 100644 --- a/sif.go +++ b/sif.go @@ -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 {