mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 + "```"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user