Merge pull request #123 from vmfunc/feat/phase3

feat: diff mode, notify (slack/discord/telegram/webhook), work-stealing pool
This commit is contained in:
celeste
2026-06-10 16:58:32 -07:00
committed by GitHub
28 changed files with 2205 additions and 283 deletions
+42
View File
@@ -220,6 +220,8 @@ write the run's findings out to a file for ci/cd or triage:
| `-sarif` | write a sarif 2.1.0 report to this file |
| `-markdown`, `-md` | write a markdown report to this file |
| `-silent` | plain output: chrome to stderr, one finding per line to stdout (for pipelines) |
| `-diff` | surface only findings added/removed since the last snapshot of each target |
| `-store` | snapshot directory for `-diff` (default: log dir, else `<user-config>/sif/state`) |
```bash
# scan and emit both a sarif and markdown report
@@ -228,6 +230,46 @@ write the run's findings out to a file for ci/cd or triage:
sarif output is ingestable by github code scanning; markdown is a readable per-target summary.
### diff mode
`-diff` turns a re-scan into a monitor: sif snapshots each target's normalized findings to a json file, and on the next run reports only the delta (`+ new` / `- gone`) against that snapshot, then overwrites it. the first run for a target has no baseline, so everything is `+ new`. snapshots land in `-store` (one sanitized file per target); when unset they reuse the log dir, falling back to `<user-config>/sif/state`.
```bash
# baseline run, then re-scan later and see only what moved
./sif -u https://example.com -sh -cors -diff
./sif -u https://example.com -sh -cors -diff
```
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:
+72
View File
@@ -442,6 +442,78 @@ plain output for pipelines: all banner/spinner/log chrome goes to stderr and std
subfinder -d example.com | sif -silent -probe -sh | notify
```
### -diff
turn a re-scan into a monitor. sif snapshots each target's normalized findings to a json file under the store dir; on the next run it loads that snapshot, diffs the current findings against it by finding key, and prints only the delta (`+ new` for findings that appeared, `- gone` for findings that vanished). it always rewrites the snapshot afterwards, so each run compares against the previous one.
the first run for a target has no snapshot, so every finding shows as `+ new`. when nothing changed, sif notes that and writes a fresh snapshot anyway.
```bash
# baseline, then re-scan and see only what moved
./sif -u https://example.com -sh -cors -diff
./sif -u https://example.com -sh -cors -diff
```
the delta is chrome, not the findings stream: under `-silent` it rides stderr with the rest of the chrome, leaving stdout for the full findings.
### -store
snapshot directory for `-diff`. precedence when unset: the `-log` dir if one is given, else `<user-config>/sif/state` (`$XDG_CONFIG_HOME/sif/state` on linux, `~/Library/Application Support/sif/state` on macos). one sanitized file per target, created at `0750`, written `0600`.
```bash
./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
+17
View File
@@ -69,6 +69,8 @@ type Settings struct {
SARIF string // path to write a sarif 2.1.0 report to ("" = off)
Markdown string // path to write a markdown report to ("" = off)
Silent bool // route chrome to stderr, print one finding per line to stdout
Diff bool // surface only findings added/removed vs the last snapshot
Store string // snapshot dir for diff mode ("" = default state dir)
Modules string // Comma-separated list of module IDs to run
ModuleTags string // Run modules matching these tags
AllModules bool // Run all loaded modules
@@ -77,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
@@ -88,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
@@ -174,6 +183,14 @@ func Parse() *Settings {
flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"),
flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"),
flagSet.BoolVar(&settings.Silent, "silent", false, "Plain output: chrome to stderr, one finding per line to stdout (for pipelines)"),
flagSet.BoolVar(&settings.Diff, "diff", false, "Diff mode: surface only findings added/removed since the last snapshot of each target"),
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",
+8
View File
@@ -61,6 +61,14 @@ func TestSettingsDefaults(t *testing.T) {
if settings.Ports != "" {
t.Errorf("expected Ports default to be empty, got %v", settings.Ports)
}
// diff mode is opt-in and its store dir defaults empty (resolved at runtime).
if settings.Diff != false {
t.Errorf("expected Diff default to be false, got %v", settings.Diff)
}
if settings.Store != "" {
t.Errorf("expected Store default to be empty, got %v", settings.Store)
}
}
func TestSettingsNoScanBehavior(t *testing.T) {
+119
View File
@@ -0,0 +1,119 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// env var names notify reads, env-first. these mirror the conventional names so
// an operator who already exports them for other tooling gets notify for free.
const (
envSlackWebhook = "SLACK_WEBHOOK_URL"
envDiscordWebhook = "DISCORD_WEBHOOK_URL"
// the name of the env var holding the bot token, not the token itself.
envTelegramToken = "TELEGRAM_BOT_TOKEN" //nolint:gosec // env var name, not a secret
envTelegramChat = "TELEGRAM_CHAT_ID"
envWebhookURL = "NOTIFY_WEBHOOK_URL"
)
// config holds resolved destinations for every provider. yaml tags use
// projectdiscovery/notify-compatible key names so an existing notify config file
// ports over verbatim; env supplies the same values and yaml overrides it.
type config struct {
SlackWebhook string `yaml:"slack_webhook_url"`
DiscordWebhook string `yaml:"discord_webhook_url"`
// telegram needs both a bot token and a chat id. notify spells the token
// "telegram_api_key", so accept that key for drop-in compatibility.
TelegramToken string `yaml:"telegram_api_key"`
TelegramChat string `yaml:"telegram_chat_id"`
WebhookURL string `yaml:"webhook_url"`
}
// loadConfig resolves notify destinations env-first, then overlays a yaml file
// when path is non-empty. yaml wins per-field so a file value overrides the
// matching env var; an unset yaml field leaves the env value intact. an empty
// path means env-only. a missing/unparseable file is an error - if the operator
// pointed -notify-config somewhere, a typo should fail loud, not silently drop.
func loadConfig(path string) (config, error) {
cfg := config{
SlackWebhook: os.Getenv(envSlackWebhook),
DiscordWebhook: os.Getenv(envDiscordWebhook),
TelegramToken: os.Getenv(envTelegramToken),
TelegramChat: os.Getenv(envTelegramChat),
WebhookURL: os.Getenv(envWebhookURL),
}
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return config{}, fmt.Errorf("read config %q: %w", path, err)
}
// decode into a separate value so only the keys present in the file overlay
// the env-derived defaults; a zero field in the yaml must not blank an env var.
var file config
if err := yaml.Unmarshal(data, &file); err != nil {
return config{}, fmt.Errorf("parse config %q: %w", path, err)
}
overlay(&cfg, &file)
return cfg, nil
}
// overlay copies non-empty fields from src onto dst. used to let a yaml file
// override env without an empty yaml key wiping out a populated env value.
func overlay(dst, src *config) {
if src.SlackWebhook != "" {
dst.SlackWebhook = src.SlackWebhook
}
if src.DiscordWebhook != "" {
dst.DiscordWebhook = src.DiscordWebhook
}
if src.TelegramToken != "" {
dst.TelegramToken = src.TelegramToken
}
if src.TelegramChat != "" {
dst.TelegramChat = src.TelegramChat
}
if src.WebhookURL != "" {
dst.WebhookURL = src.WebhookURL
}
}
// providers builds the live provider list from the resolved config: a provider
// is included only when its destination is fully specified. telegram needs both
// token and chat id, so a half-configured telegram is dropped rather than POSTing
// to a broken endpoint.
func (c *config) providers() []provider {
var out []provider
if c.SlackWebhook != "" {
out = append(out, &slackProvider{webhook: c.SlackWebhook})
}
if c.DiscordWebhook != "" {
out = append(out, &discordProvider{webhook: c.DiscordWebhook})
}
if c.TelegramToken != "" && c.TelegramChat != "" {
out = append(out, &telegramProvider{token: c.TelegramToken, chatID: c.TelegramChat})
}
if c.WebhookURL != "" {
out = append(out, &webhookProvider{url: c.WebhookURL})
}
return out
}
+153
View File
@@ -0,0 +1,153 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"os"
"path/filepath"
"testing"
)
// clearNotifyEnv unsets every var loadConfig reads so a test starts from a known
// blank slate; t.Setenv("", "") still records the var for cleanup restoration.
func clearNotifyEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
envSlackWebhook, envDiscordWebhook,
envTelegramToken, envTelegramChat, envWebhookURL,
} {
t.Setenv(k, "")
}
}
func TestLoadConfigEnvOnly(t *testing.T) {
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, "https://hooks.slack.test/a")
t.Setenv(envTelegramToken, "123:abc")
t.Setenv(envTelegramChat, "999")
cfg, err := loadConfig("")
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.SlackWebhook != "https://hooks.slack.test/a" {
t.Errorf("slack webhook = %q, want from env", cfg.SlackWebhook)
}
if cfg.TelegramToken != "123:abc" || cfg.TelegramChat != "999" {
t.Errorf("telegram = %q/%q, want from env", cfg.TelegramToken, cfg.TelegramChat)
}
// slack + telegram (both halves) configured, discord/webhook empty.
got := cfg.providers()
if len(got) != 2 {
t.Fatalf("providers = %d, want 2 (slack, telegram)", len(got))
}
wantNames := map[string]bool{"slack": false, "telegram": false}
for _, p := range got {
wantNames[p.name()] = true
}
for name, seen := range wantNames {
if !seen {
t.Errorf("provider %q missing", name)
}
}
}
func TestLoadConfigYAMLOverridesEnv(t *testing.T) {
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, "https://env.slack.test/x")
t.Setenv(envWebhookURL, "https://env.webhook.test/x")
body := "" +
"slack_webhook_url: https://file.slack.test/y\n" +
"discord_webhook_url: https://file.discord.test/z\n"
path := writeTempConfig(t, body)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
// yaml present -> overrides env.
if cfg.SlackWebhook != "https://file.slack.test/y" {
t.Errorf("slack = %q, want yaml override", cfg.SlackWebhook)
}
// yaml absent for webhook -> env value survives.
if cfg.WebhookURL != "https://env.webhook.test/x" {
t.Errorf("webhook = %q, want env value preserved", cfg.WebhookURL)
}
// yaml introduces discord.
if cfg.DiscordWebhook != "https://file.discord.test/z" {
t.Errorf("discord = %q, want from yaml", cfg.DiscordWebhook)
}
}
func TestLoadConfigNotifyCompatibleTelegramKey(t *testing.T) {
clearNotifyEnv(t)
// projectdiscovery/notify spells the bot token "telegram_api_key"; assert a
// drop-in config wires telegram from that key.
body := "" +
"telegram_api_key: 555:tok\n" +
"telegram_chat_id: \"42\"\n"
path := writeTempConfig(t, body)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.TelegramToken != "555:tok" || cfg.TelegramChat != "42" {
t.Fatalf("telegram = %q/%q, want from notify-compatible keys", cfg.TelegramToken, cfg.TelegramChat)
}
if len(cfg.providers()) != 1 {
t.Fatalf("providers = %d, want 1 (telegram)", len(cfg.providers()))
}
}
func TestLoadConfigMissingFileErrors(t *testing.T) {
clearNotifyEnv(t)
if _, err := loadConfig(filepath.Join(t.TempDir(), "nope.yaml")); err == nil {
t.Fatal("loadConfig with missing file: want error, got nil")
}
}
func TestLoadConfigBadYAMLErrors(t *testing.T) {
clearNotifyEnv(t)
path := writeTempConfig(t, "slack_webhook_url: [unterminated\n")
if _, err := loadConfig(path); err == nil {
t.Fatal("loadConfig with malformed yaml: want error, got nil")
}
}
func TestProvidersTelegramNeedsBothHalves(t *testing.T) {
// token without chat id must not produce a (broken) telegram provider.
cfg := config{TelegramToken: "tok"}
if got := cfg.providers(); len(got) != 0 {
t.Fatalf("providers = %d, want 0 for half-configured telegram", len(got))
}
}
func TestProvidersEmptyConfigIsNone(t *testing.T) {
var cfg config
if got := cfg.providers(); len(got) != 0 {
t.Fatalf("providers = %d, want 0 for empty config", len(got))
}
}
// writeTempConfig writes body to a temp yaml file and returns its path.
func writeTempConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "notify.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write temp config: %v", err)
}
return path
}
+39
View File
@@ -0,0 +1,39 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// discordProvider posts to a discord webhook. discord's incoming-webhook body
// keys the message on "content" (slack uses "text"); same code-block wrapping so
// the finding columns line up in the channel.
type discordProvider struct {
webhook string
}
func (d *discordProvider) name() string { return "discord" }
// discordPayload is the minimal webhook body: a single content field.
type discordPayload struct {
Content string `json:"content"`
}
func (d *discordProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
payload := discordPayload{Content: codeBlock(renderFindings(findings))}
return postJSON(ctx, client, d.webhook, payload)
}
+74
View File
@@ -0,0 +1,74 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/httpx"
)
// contentTypeJSON is the body type every provider POSTs; all four speak json.
const contentTypeJSON = "application/json"
// messageHeader prefixes the rendered finding block. kept terse - chat sinks
// truncate, so the count and lead-in carry the signal.
const messageHeader = "sif found %d finding(s):"
// renderFindings turns a batch into a single plain-text block, one finding per
// line in the same "[severity] target module title" shape as the -silent sink so
// a reader sees identical lines across stdout and chat. a strings.Builder keeps
// the per-line concat to one allocation path.
func renderFindings(findings []finding.Finding) string {
var b strings.Builder
fmt.Fprintf(&b, messageHeader, len(findings))
b.WriteByte('\n')
for i := 0; i < len(findings); i++ {
b.WriteString(findings[i].Line())
b.WriteByte('\n')
}
return b.String()
}
// postJSON marshals payload and POSTs it to url through the shared client. it
// drains+closes the response so the conn returns to httpx's pool, and treats any
// non-2xx as a delivery failure so a 4xx from a bad webhook surfaces loudly.
func postJSON(ctx context.Context, client *http.Client, url string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", contentTypeJSON)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return fmt.Errorf("post: %w", err)
}
defer httpx.DrainClose(resp)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package notify ships findings to chat/webhook sinks (slack, discord, telegram,
// generic webhook) so a continuous-recon run can alert on what it turns up. every
// provider is one POST through httpx.Client, so the global proxy/rate-limit/header
// config applies uniformly and there's no extra http stack to keep in sync.
package notify
import (
"context"
"fmt"
"net/http"
"time"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/output"
)
// Options carries the runtime knobs Send needs. Timeout bounds each provider's
// POST; ConfigPath is an optional yaml file whose values override env. severity
// filtering is the caller's job - Send ships whatever batch it's handed.
type Options struct {
Timeout time.Duration
ConfigPath string
}
// Send dispatches findings to every configured provider. config resolves
// env-first, then a yaml file overlays it (notify-compatible key names). a
// provider with no destination is skipped, so zero configured providers makes
// Send a silent no-op - notify is opt-in and never errors just for being unwired.
// an empty findings slice is also a no-op: nothing to report.
func Send(ctx context.Context, findings []finding.Finding, opts Options) error {
if len(findings) == 0 {
return nil
}
cfg, err := loadConfig(opts.ConfigPath)
if err != nil {
return fmt.Errorf("notify config: %w", err)
}
providers := cfg.providers()
if len(providers) == 0 {
// nothing wired up; opt-in feature stays quiet rather than erroring.
return nil
}
log := output.Module("NOTIFY")
client := httpx.Client(opts.Timeout)
// run every provider; a failure on one sink must not suppress the others, so
// errors accumulate and the first is returned after all have been attempted.
var firstErr error
for i := 0; i < len(providers); i++ {
p := providers[i]
if err := p.send(ctx, client, findings); err != nil {
log.Error("%s delivery failed: %v", p.name(), err)
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", p.name(), err)
}
continue
}
log.Success("sent %d findings to %s", len(findings), p.name())
}
return firstErr
}
// provider is one delivery sink. name is for logging; send formats findings into
// the sink's payload and POSTs it through the shared client.
type provider interface {
name() string
send(ctx context.Context, client *http.Client, findings []finding.Finding) error
}
+224
View File
@@ -0,0 +1,224 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/finding"
)
// sampleFindings returns a small mixed-severity batch for payload assertions.
func sampleFindings() []finding.Finding {
return []finding.Finding{
{Target: "https://a.test", Module: "cors", Severity: finding.SeverityHigh, Key: "cors:a", Title: "reflected origin", Raw: "ACAO echo"},
{Target: "https://a.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:x", Title: "Server header", Raw: "nginx"},
}
}
// capture records the method, content-type and raw body of the request a provider
// makes, so each test can assert the wire shape without a real network.
type capture struct {
method string
contentType string
path string
body []byte
}
// captureServer stands up an httptest server that records the single inbound
// request into c and replies 200, the happy path every provider expects.
func captureServer(t *testing.T, c *capture) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
c.method = r.Method
c.contentType = r.Header.Get("Content-Type")
c.path = r.URL.Path
c.body = body
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv
}
func TestSlackPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &slackProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("slack send: %v", err)
}
assertPostJSON(t, c)
var payload slackPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal slack body: %v", err)
}
// slack keys on "text"; both findings must appear, code-block fenced.
if !strings.Contains(payload.Text, "reflected origin") || !strings.Contains(payload.Text, "Server header") {
t.Errorf("slack text missing findings: %q", payload.Text)
}
if !strings.HasPrefix(payload.Text, "```") {
t.Errorf("slack text not code-block fenced: %q", payload.Text)
}
}
func TestDiscordPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &discordProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("discord send: %v", err)
}
assertPostJSON(t, c)
var payload discordPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal discord body: %v", err)
}
// discord keys on "content", not "text".
if !strings.Contains(payload.Content, "reflected origin") {
t.Errorf("discord content missing finding: %q", payload.Content)
}
}
func TestTelegramPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
// repoint the bot api base at the test server for the lifetime of this test.
orig := telegramAPIBase
telegramAPIBase = srv.URL
t.Cleanup(func() { telegramAPIBase = orig })
p := &telegramProvider{token: "555:tok", chatID: "42"}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("telegram send: %v", err)
}
assertPostJSON(t, c)
// the token rides the path and the method is sendMessage.
if c.path != "/bot555:tok/sendMessage" {
t.Errorf("telegram path = %q, want /bot555:tok/sendMessage", c.path)
}
var payload telegramPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal telegram body: %v", err)
}
if payload.ChatID != "42" {
t.Errorf("telegram chat_id = %q, want 42", payload.ChatID)
}
if !strings.Contains(payload.Text, "reflected origin") {
t.Errorf("telegram text missing finding: %q", payload.Text)
}
}
func TestWebhookPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &webhookProvider{url: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("webhook send: %v", err)
}
assertPostJSON(t, c)
var payload webhookPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal webhook body: %v", err)
}
// generic webhook carries structured findings, not a prerendered blob.
if payload.Count != 2 || len(payload.Findings) != 2 {
t.Fatalf("webhook count = %d / %d findings, want 2", payload.Count, len(payload.Findings))
}
first := payload.Findings[0]
if first.Severity != "high" {
t.Errorf("webhook severity = %q, want canonical string \"high\"", first.Severity)
}
if first.Key != "cors:a" || first.Module != "cors" {
t.Errorf("webhook finding fields wrong: %+v", first)
}
}
func TestProviderNon2xxIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
p := &slackProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err == nil {
t.Fatal("send to 403 endpoint: want error, got nil")
}
}
func TestSendNoProviderIsNoop(t *testing.T) {
clearNotifyEnv(t)
// no env, no config file -> zero providers -> Send must not error.
if err := Send(context.Background(), sampleFindings(), Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send with no provider: want nil, got %v", err)
}
}
func TestSendEmptyFindingsIsNoop(t *testing.T) {
// even with a provider configured, an empty batch must not POST anything.
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, srv.URL)
if err := Send(context.Background(), nil, Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send with empty findings: want nil, got %v", err)
}
if hit {
t.Fatal("Send with empty findings posted to provider, want no-op")
}
}
func TestSendDeliversToConfiguredProvider(t *testing.T) {
var c capture
srv := captureServer(t, &c)
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, srv.URL)
if err := Send(context.Background(), sampleFindings(), Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send: %v", err)
}
if c.method != http.MethodPost {
t.Fatalf("provider not hit (method=%q)", c.method)
}
}
// assertPostJSON checks the request was a json POST.
func assertPostJSON(t *testing.T, c capture) {
t.Helper()
if c.method != http.MethodPost {
t.Errorf("method = %q, want POST", c.method)
}
if c.contentType != contentTypeJSON {
t.Errorf("content-type = %q, want %q", c.contentType, contentTypeJSON)
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// slackProvider posts to a slack incoming webhook. the webhook url already pins
// the channel, so the payload is just the rendered text in slack's mrkdwn-aware
// "text" field wrapped in a code block to keep the fixed-width finding lines.
type slackProvider struct {
webhook string
}
func (s *slackProvider) name() string { return "slack" }
// slackPayload is the minimal incoming-webhook body: a single text field.
type slackPayload struct {
Text string `json:"text"`
}
func (s *slackProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
payload := slackPayload{Text: codeBlock(renderFindings(findings))}
return postJSON(ctx, client, s.webhook, payload)
}
// codeBlock wraps body in a triple-backtick fence; both slack and discord render
// it fixed-width, which preserves the column-aligned finding lines.
func codeBlock(body string) string {
return "```\n" + body + "```"
}
+48
View File
@@ -0,0 +1,48 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// telegramAPIBase is the bot api root. it's a var so tests can repoint it at an
// httptest server; the token is appended path-side per telegram's scheme.
var telegramAPIBase = "https://api.telegram.org"
// telegramProvider posts via the bot api's sendMessage. unlike slack/discord the
// destination isn't a single opaque webhook: it needs the bot token (in the url
// path) plus the chat id (in the body).
type telegramProvider struct {
token string
chatID string
}
func (t *telegramProvider) name() string { return "telegram" }
// telegramPayload is the sendMessage body. parse_mode "MarkdownV2" would force
// escaping every special char in the finding lines, so we send plain text and
// let the lines stand as-is.
type telegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
func (t *telegramProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
endpoint := telegramAPIBase + "/bot" + t.token + "/sendMessage"
payload := telegramPayload{ChatID: t.chatID, Text: renderFindings(findings)}
return postJSON(ctx, client, endpoint, payload)
}
+65
View File
@@ -0,0 +1,65 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// webhookProvider posts a structured json payload to an arbitrary endpoint. unlike
// the chat sinks it carries the findings as data, not a prerendered blob, so
// downstream automation (a siem, a bot, ci) keys off the fields directly.
type webhookProvider struct {
url string
}
func (w *webhookProvider) name() string { return "webhook" }
// webhookFinding is the per-item wire shape: the normalized Finding fields with
// severity flattened to its canonical string so a json consumer never sees the
// internal integer rank.
type webhookFinding struct {
Target string `json:"target"`
Module string `json:"module"`
Severity string `json:"severity"`
Key string `json:"key"`
Title string `json:"title"`
Raw string `json:"raw,omitempty"`
}
// webhookPayload wraps the batch with a count so a consumer can size buffers /
// assert completeness without walking the slice first.
type webhookPayload struct {
Count int `json:"count"`
Findings []webhookFinding `json:"findings"`
}
func (w *webhookProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
items := make([]webhookFinding, 0, len(findings))
for i := 0; i < len(findings); i++ {
f := findings[i]
items = append(items, webhookFinding{
Target: f.Target,
Module: f.Module,
Severity: f.Severity.String(),
Key: f.Key,
Title: f.Title,
Raw: f.Raw,
})
}
payload := webhookPayload{Count: len(items), Findings: items}
return postJSON(ctx, client, w.url, payload)
}
+57
View File
@@ -0,0 +1,57 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package pool spreads independent per-item work across a fixed set of workers
// that all pull from one shared channel. that's the point over a static
// modulo-stride partition: a slow or timing-out item only stalls the one worker
// holding it, the rest keep draining the queue instead of idling behind it.
package pool
import "sync"
// Each runs fn for every item in items, concurrently, across at most workers
// goroutines. order isn't preserved - fn must be safe to call from multiple
// goroutines and guard any shared state itself. blocks until every item is done.
func Each[T any](items []T, workers int, fn func(T)) {
if len(items) == 0 {
return
}
// floor at one worker; a non-positive count would otherwise spawn nothing
// and silently drop the work.
if workers < 1 {
workers = 1
}
// never spin more workers than there is work for.
if workers > len(items) {
workers = len(items)
}
queue := make(chan T, len(items))
for i := 0; i < len(items); i++ {
queue <- items[i]
}
close(queue)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
// pull until the queue is drained; a worker that finishes its
// current item just grabs the next, which is the work-stealing.
for item := range queue {
fn(item)
}
}()
}
wg.Wait()
}
+145
View File
@@ -0,0 +1,145 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package pool
import (
"sync"
"sync/atomic"
"testing"
)
// every item runs exactly once across a spread of sizes and worker counts,
// including the floors (zero/negative workers) and workers > len.
func TestEachProcessesAllExactlyOnce(t *testing.T) {
tests := []struct {
name string
items int
workers int
}{
{"empty", 0, 4},
{"single item", 1, 8},
{"workers floored from zero", 5, 0},
{"workers floored from negative", 5, -3},
{"more workers than items", 3, 16},
{"even split", 100, 4},
{"uneven split", 101, 7},
{"one worker", 50, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
items := make([]int, tt.items)
for i := 0; i < tt.items; i++ {
items[i] = i
}
var mu sync.Mutex
seen := make(map[int]int, tt.items)
Each(items, tt.workers, func(v int) {
mu.Lock()
seen[v]++
mu.Unlock()
})
if len(seen) != tt.items {
t.Fatalf("processed %d distinct items, want %d", len(seen), tt.items)
}
for v, n := range seen {
if n != 1 {
t.Errorf("item %d processed %d times, want 1", v, n)
}
}
})
}
}
// no more than `workers` (capped at len(items)) callbacks ever run at once.
func TestEachRespectsWorkerCap(t *testing.T) {
const (
items = 200
workers = 6
)
work := make([]int, items)
var inFlight, peak int64
var release = make(chan struct{})
var started sync.WaitGroup
started.Add(items)
go func() {
Each(work, workers, func(int) {
cur := atomic.AddInt64(&inFlight, 1)
for {
p := atomic.LoadInt64(&peak)
if cur <= p || atomic.CompareAndSwapInt64(&peak, p, cur) {
break
}
}
started.Done()
<-release
atomic.AddInt64(&inFlight, -1)
})
}()
// the cap means at most `workers` callbacks block on release at once, so
// release exactly that many at a time until everything drains.
done := make(chan struct{})
go func() {
for i := 0; i < items; i++ {
release <- struct{}{}
}
close(done)
}()
<-done
if got := atomic.LoadInt64(&peak); got > workers {
t.Fatalf("peak concurrency %d exceeded worker cap %d", got, workers)
}
}
// the cap is min(workers, len(items)): fewer items than workers must not spin
// idle goroutines past the item count.
func TestEachCapsAtItemCount(t *testing.T) {
const (
items = 3
workers = 32
)
work := make([]int, items)
var inFlight, peak int64
var ready sync.WaitGroup
ready.Add(items)
release := make(chan struct{})
go func() {
for i := 0; i < items; i++ {
release <- struct{}{}
}
}()
Each(work, workers, func(int) {
cur := atomic.AddInt64(&inFlight, 1)
for {
p := atomic.LoadInt64(&peak)
if cur <= p || atomic.CompareAndSwapInt64(&peak, p, cur) {
break
}
}
<-release
atomic.AddInt64(&inFlight, -1)
})
if got := atomic.LoadInt64(&peak); got > items {
t.Fatalf("peak concurrency %d exceeded item count %d", got, items)
}
}
+40 -52
View File
@@ -29,6 +29,7 @@ import (
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
)
// directoryURL is a var so integration tests can repoint it at a fixture.
@@ -413,67 +414,54 @@ func Dirlist(size string, url string, timeout time.Duration, threads int, logdir
progress := output.NewProgress(len(directories), "fuzzing")
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
results := make(DirectoryResults, 0, 64)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
pool.Each(directories, threads, func(directory string) {
progress.Increment(directory)
for i, directory := range directories {
if i%threads != thread {
continue
}
charmlog.Debugf("%s", directory)
dirReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+directory, http.NoBody)
if err != nil {
charmlog.Debugf("Error creating request for %s: %s", directory, err)
return
}
resp, err := client.Do(dirReq)
if err != nil {
charmlog.Debugf("Error %s: %s", directory, err)
return
}
progress.Increment(directory)
meta, body := readMeta(resp)
reqURL := resp.Request.URL.String()
resp.Body.Close()
charmlog.Debugf("%s", directory)
dirReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+directory, http.NoBody)
if err != nil {
charmlog.Debugf("Error creating request for %s: %s", directory, err)
continue
}
resp, err := client.Do(dirReq)
if err != nil {
charmlog.Debugf("Error %s: %s", directory, err)
continue
}
if !matcher.Matches(meta, body) {
return
}
meta, body := readMeta(resp)
reqURL := resp.Request.URL.String()
resp.Body.Close()
progress.Pause()
log.Success("found: %s [%s] (size=%d words=%d)",
output.Highlight.Render(directory),
output.Status.Render(strconv.Itoa(meta.status)),
meta.size, meta.words)
progress.Resume()
if !matcher.Matches(meta, body) {
continue
}
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir,
fmt.Sprintf("%s [%s] size=%d words=%d\n", strconv.Itoa(meta.status), directory, meta.size, meta.words))
}
progress.Pause()
log.Success("found: %s [%s] (size=%d words=%d)",
output.Highlight.Render(directory),
output.Status.Render(strconv.Itoa(meta.status)),
meta.size, meta.words)
progress.Resume()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir,
fmt.Sprintf("%s [%s] size=%d words=%d\n", strconv.Itoa(meta.status), directory, meta.size, meta.words))
}
result := DirectoryResult{
Url: reqURL,
StatusCode: meta.status,
Size: meta.size,
Words: meta.words,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}
}(thread)
}
wg.Wait()
result := DirectoryResult{
Url: reqURL,
StatusCode: meta.status,
Size: meta.size,
Words: meta.words,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
})
progress.Done()
log.Complete(len(results), "found")
+33 -45
View File
@@ -25,6 +25,7 @@ import (
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
)
// dnsURL is a var so integration tests can repoint it at a fixture.
@@ -148,61 +149,48 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
progress := output.NewProgress(len(dns), "enumerating")
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
urls := make([]string, 0, 64)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
pool.Each(dns, threads, func(domain string) {
progress.Increment(domain)
for i, domain := range dns {
if i%threads != thread {
continue
}
charmlog.Debugf("Looking up: %s", domain)
progress.Increment(domain)
host := domain + "." + sanitizedURL
charmlog.Debugf("Looking up: %s", domain)
// dns gate: skip the http probe entirely for names that don't
// resolve or that a wildcard zone answers. this is the whole point -
// no request per dead candidate.
ok, err := resolver.Resolve(host)
if err != nil {
charmlog.Debugf("resolve %s: %s", host, err)
return
}
if !ok {
return
}
host := domain + "." + sanitizedURL
// probe http first, then https - but a subdomain is recorded at
// most once. firing both schemes and appending on each is what
// double-counted every host on the old path.
foundURL, scheme := probeSubdomain(client, host)
if foundURL == "" {
return
}
// dns gate: skip the http probe entirely for names that don't
// resolve or that a wildcard zone answers. this is the whole point -
// no request per dead candidate.
ok, err := resolver.Resolve(host)
if err != nil {
charmlog.Debugf("resolve %s: %s", host, err)
continue
}
if !ok {
continue
}
mu.Lock()
urls = append(urls, foundURL)
mu.Unlock()
// probe http first, then https - but a subdomain is recorded at
// most once. firing both schemes and appending on each is what
// double-counted every host on the old path.
foundURL, scheme := probeSubdomain(client, host)
if foundURL == "" {
continue
}
progress.Pause()
log.Success("found: %s [%s]", output.Highlight.Render(host), scheme)
progress.Resume()
mu.Lock()
urls = append(urls, foundURL)
mu.Unlock()
progress.Pause()
log.Success("found: %s [%s]", output.Highlight.Render(host), scheme)
progress.Resume()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("[%s] %s\n", scheme, host))
}
}
}(thread)
}
wg.Wait()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("[%s] %s\n", scheme, host))
}
})
progress.Done()
log.Complete(len(urls), "found")
+24 -37
View File
@@ -28,6 +28,7 @@ import (
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
googlesearch "github.com/rocketlaunchr/google-search"
)
@@ -92,47 +93,33 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork
}
// util.InitProgressBar()
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
dorkResults := []DorkResult{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, dork := range dorks {
if i%threads != thread {
continue
}
results, err := googlesearch.Search(context.TODO(), fmt.Sprintf("%s %s", dork, sanitizedURL))
if err != nil {
log.Debugf("error searching for dork %s: %v", dork, err)
continue
}
if len(results) > 0 {
spin.Stop()
output.Success("%s dork results found for dork %s", output.Status.Render(strconv.Itoa(len(results))), output.Highlight.Render(dork))
spin.Start()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, strconv.Itoa(len(results))+" dork results found for dork ["+dork+"]\n")
}
result := DorkResult{
Url: dork,
Count: len(results),
}
mu.Lock()
dorkResults = append(dorkResults, result)
mu.Unlock()
}
pool.Each(dorks, threads, func(dork string) {
results, err := googlesearch.Search(context.TODO(), fmt.Sprintf("%s %s", dork, sanitizedURL))
if err != nil {
log.Debugf("error searching for dork %s: %v", dork, err)
return
}
if len(results) > 0 {
spin.Stop()
output.Success("%s dork results found for dork %s", output.Status.Render(strconv.Itoa(len(results))), output.Highlight.Render(dork))
spin.Start()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, strconv.Itoa(len(results))+" dork results found for dork ["+dork+"]\n")
}
}(thread)
}
wg.Wait()
result := DorkResult{
Url: dork,
Count: len(results),
}
mu.Lock()
dorkResults = append(dorkResults, result)
mu.Unlock()
}
})
spin.Stop()
output.ScanComplete("URL dorking", len(dorkResults), "found")
+27 -39
View File
@@ -25,6 +25,7 @@ import (
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
)
// gitURL is a var so integration tests can repoint it at a fixture.
@@ -71,50 +72,37 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin
gitUrls = append(gitUrls, scanner.Text())
}
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
foundUrls := []string{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
pool.Each(gitUrls, threads, func(repourl string) {
charmlog.Debugf("%s", repourl)
gitReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+repourl, http.NoBody)
if err != nil {
charmlog.Debugf("Error creating request for %s: %s", repourl, err)
return
}
resp, err := client.Do(gitReq) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("Error %s: %s", repourl, err)
return
}
for i, repourl := range gitUrls {
if i%threads != thread {
continue
}
charmlog.Debugf("%s", repourl)
gitReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+repourl, http.NoBody)
if err != nil {
charmlog.Debugf("Error creating request for %s: %s", repourl, err)
continue
}
resp, err := client.Do(gitReq) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("Error %s: %s", repourl, err)
continue
}
if resp.StatusCode == 200 && !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
spin.Stop()
log.Success("Git found at %s [%s]", output.Highlight.Render(repourl), output.Status.Render(strconv.Itoa(resp.StatusCode)))
spin.Start()
if logdir != "" {
logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" git found at ["+repourl+"]\n")
}
mu.Lock()
foundUrls = append(foundUrls, resp.Request.URL.String())
mu.Unlock()
}
// status/headers only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if resp.StatusCode == 200 && !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
spin.Stop()
log.Success("Git found at %s [%s]", output.Highlight.Render(repourl), output.Status.Render(strconv.Itoa(resp.StatusCode)))
spin.Start()
if logdir != "" {
logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" git found at ["+repourl+"]\n")
}
}(thread)
}
wg.Wait()
mu.Lock()
foundUrls = append(foundUrls, resp.Request.URL.String())
mu.Unlock()
}
// status/headers only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
})
spin.Stop()
log.Complete(len(foundUrls), "found")
+18 -30
View File
@@ -26,6 +26,7 @@ import (
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
)
// commonPorts is a var so integration tests can repoint it at a fixture.
@@ -75,39 +76,26 @@ func Ports(ctx context.Context, scope string, url string, timeout time.Duration,
var openPorts []string
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(threads)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
pool.Each(ports, threads, func(port int) {
progress.Increment(strconv.Itoa(port))
for i, port := range ports {
if i%threads != thread {
continue
}
charmlog.Debugf("Looking up: %d", port)
addr := fmt.Sprintf("%s:%d", sanitizedURL, port)
tcp, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", addr)
if err != nil {
charmlog.Debugf("Error %d: %v", port, err)
} else {
progress.Pause()
log.Success("open: %s:%s [tcp]", sanitizedURL, output.Highlight.Render(strconv.Itoa(port)))
progress.Resume()
progress.Increment(strconv.Itoa(port))
charmlog.Debugf("Looking up: %d", port)
addr := fmt.Sprintf("%s:%d", sanitizedURL, port)
tcp, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", addr)
if err != nil {
charmlog.Debugf("Error %d: %v", port, err)
} else {
progress.Pause()
log.Success("open: %s:%s [tcp]", sanitizedURL, output.Highlight.Render(strconv.Itoa(port)))
progress.Resume()
mu.Lock()
openPorts = append(openPorts, strconv.Itoa(port))
mu.Unlock()
_ = tcp.Close()
}
}
}(thread)
}
wg.Wait()
mu.Lock()
openPorts = append(openPorts, strconv.Itoa(port))
mu.Unlock()
_ = tcp.Close()
}
})
progress.Done()
log.Complete(len(openPorts), "open")
+25 -39
View File
@@ -23,13 +23,13 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
)
// stripScheme drops the scheme:// prefix from url, or returns it unchanged when
@@ -130,46 +130,32 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) {
robotsData = append(robotsData, scanner.Text())
}
var wg sync.WaitGroup
wg.Add(threads)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
pool.Each(robotsData, threads, func(robot string) {
if robot == "" || strings.HasPrefix(robot, "#") || strings.HasPrefix(robot, "User-agent: ") || strings.HasPrefix(robot, "Sitemap: ") {
return
}
for i, robot := range robotsData {
if i%threads != thread {
continue
}
_, sanitizedRobot, _ := strings.Cut(robot, ": ")
log.Debugf("%s", robot)
robotReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+sanitizedRobot, http.NoBody)
if err != nil {
log.Debugf("Error creating request for %s: %s", sanitizedRobot, err)
return
}
resp, err := client.Do(robotReq) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
log.Debugf("Error %s: %s", sanitizedRobot, err)
return
}
if robot == "" || strings.HasPrefix(robot, "#") || strings.HasPrefix(robot, "User-agent: ") || strings.HasPrefix(robot, "Sitemap: ") {
continue
}
_, sanitizedRobot, _ := strings.Cut(robot, ": ")
log.Debugf("%s", robot)
robotReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+sanitizedRobot, http.NoBody)
if err != nil {
log.Debugf("Error creating request for %s: %s", sanitizedRobot, err)
continue
}
resp, err := client.Do(robotReq) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
log.Debugf("Error %s: %s", sanitizedRobot, err)
continue
}
if resp.StatusCode != 404 {
output.Success("%s from robots: %s", output.Status.Render(strconv.Itoa(resp.StatusCode)), output.Highlight.Render(sanitizedRobot))
if logdir != "" {
logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" from robots: ["+sanitizedRobot+"]\n")
}
}
// status only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if resp.StatusCode != 404 {
output.Success("%s from robots: %s", output.Status.Render(strconv.Itoa(resp.StatusCode)), output.Highlight.Render(sanitizedRobot))
if logdir != "" {
logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" from robots: ["+sanitizedRobot+"]\n")
}
}(thread)
}
wg.Wait()
}
// status only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
})
}
}
+20 -35
View File
@@ -20,12 +20,12 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/pool"
"github.com/dropalldatabases/sif/internal/styles"
)
@@ -87,44 +87,29 @@ func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, t
client := httpx.Client(timeout)
var wg sync.WaitGroup
wg.Add(threads)
// buffered to the full candidate count so a send never blocks: Each only
// returns once every worker is done, and the channel is drained afterwards.
resultsChan := make(chan SubdomainTakeoverResult, len(dnsResults))
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
pool.Each(dnsResults, threads, func(subdomain string) {
vulnerable, service := checkSubdomainTakeover(subdomain, client)
result := SubdomainTakeoverResult{
Subdomain: subdomain,
Vulnerable: vulnerable,
Service: service,
}
resultsChan <- result
for i, subdomain := range dnsResults {
if i%threads != thread {
continue
}
vulnerable, service := checkSubdomainTakeover(subdomain, client)
result := SubdomainTakeoverResult{
Subdomain: subdomain,
Vulnerable: vulnerable,
Service: service,
}
resultsChan <- result
if vulnerable {
subdomainlog.Warnf("Potential subdomain takeover: %s (%s)", styles.Highlight.Render(subdomain), service)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Potential subdomain takeover: %s (%s)\n", subdomain, service))
}
} else {
subdomainlog.Infof("Subdomain not vulnerable: %s", subdomain)
}
if vulnerable {
subdomainlog.Warnf("Potential subdomain takeover: %s (%s)", styles.Highlight.Render(subdomain), service)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Potential subdomain takeover: %s (%s)\n", subdomain, service))
}
}(thread)
}
go func() {
wg.Wait()
close(resultsChan)
}()
} else {
subdomainlog.Infof("Subdomain not vulnerable: %s", subdomain)
}
})
close(resultsChan)
var results []SubdomainTakeoverResult
for result := range resultsChan {
+204
View File
@@ -0,0 +1,204 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package store persists a run's normalized findings as a json snapshot, one
// file per target, so a later run can diff against it and surface only what
// changed. it leans on encoding/json + os only - no new deps - and keys the
// delta off finding.Key, the identity the finding layer already guarantees is
// stable across runs.
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/dropalldatabases/sif/internal/finding"
)
// snapshotFileMode is applied to written snapshot files: owner read/write only.
// a snapshot enumerates a target's findings (urls, secrets, takeovers) and is
// not meant for other users on the box, so it stays 0600.
const snapshotFileMode = 0o600
// stateDirMode is applied to directories the store creates: owner rwx, group rx,
// no world access. matches the 0o750 the bundle asks for so the state tree isn't
// world-readable.
const stateDirMode = 0o750
// snapshotExt is the extension every snapshot file carries; makes the state dir
// self-describing and lets Load reconstruct the path from a bare target.
const snapshotExt = ".json"
// defaultDirName is the sif-owned subdirectory under the user's config dir when
// no explicit store dir is given. DefaultDir joins it under os.UserConfigDir().
const defaultDirName = "sif"
// stateSubDir separates snapshots from anything else sif might drop in its
// config dir later, so the state tree is a single sweepable directory.
const stateSubDir = "state"
// DefaultDir returns the fallback snapshot location: <user-config>/sif/state.
// callers pass it when -store is unset and there's no logdir to reuse. the dir
// is not created here - Save does that lazily so a diff-less run touches nothing.
func DefaultDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolving user config dir: %w", err)
}
return filepath.Join(configDir, defaultDirName, stateSubDir), nil
}
// sanitize turns an arbitrary target (https://example.com:8443/path?q=1) into a
// single safe filename component. a target is attacker-influenced (it can come
// from a stdin pipe or a -f file), so every separator and path metacharacter is
// folded to '_' - no '/', '\\', '.', ':' survives to escape the state dir or
// collide with a parent reference. empty/degenerate input falls back to a fixed
// token rather than producing a dotfile or empty name.
func sanitize(target string) string {
var b strings.Builder
b.Grow(len(target))
// collapse runs of separators: a scheme like "https://" is three metachars
// in a row, and one '_' reads cleaner than three without losing uniqueness.
prevSep := false
for i := 0; i < len(target); i++ {
c := target[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-':
b.WriteByte(c)
prevSep = false
default:
// every other byte (path sep, dot, colon, slash, space, unicode, and a
// literal '_') is a separator; fold it so traversal and dotfiles are
// impossible and a run never balloons the filename.
if !prevSep {
b.WriteByte('_')
prevSep = true
}
}
}
name := strings.Trim(b.String(), "_")
if name == "" {
return "target"
}
return name
}
// pathFor builds the absolute snapshot path for a target under dir. kept private
// so the sanitized-filename invariant lives in one place; Save and Load both go
// through it so a target always maps to the same file.
func pathFor(dir, target string) string {
return filepath.Join(dir, sanitize(target)+snapshotExt)
}
// Save writes the run's findings for target as a json snapshot under dir,
// overwriting any prior snapshot. the dir (and parents) is created lazily with
// stateDirMode. an empty findings slice is still written - it records "this
// target had nothing", which a later diff reads as a clean baseline rather than
// a missing one.
func Save(dir, target string, findings []finding.Finding) error {
if dir == "" {
return fmt.Errorf("store: empty snapshot dir")
}
if err := os.MkdirAll(dir, stateDirMode); err != nil {
return fmt.Errorf("creating state dir %q: %w", dir, err)
}
// marshal a non-nil slice so an empty run serializes to [] not null; keeps
// the on-disk shape stable and Load's decode unambiguous.
if findings == nil {
findings = []finding.Finding{}
}
data, err := json.MarshalIndent(findings, "", " ")
if err != nil {
return fmt.Errorf("marshaling snapshot for %q: %w", target, err)
}
path := pathFor(dir, target)
if err := os.WriteFile(path, data, snapshotFileMode); err != nil {
return fmt.Errorf("writing snapshot %q: %w", path, err)
}
return nil
}
// Load reads the previously saved snapshot for target under dir. a missing
// snapshot is not an error - it's the first run for that target, so an empty
// slice comes back and the caller treats every current finding as new. a present
// but unreadable/corrupt file is a real error: silently swallowing it would make
// a broken store look like a fresh one and flag everything as added forever.
func Load(dir, target string) ([]finding.Finding, error) {
path := pathFor(dir, target)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []finding.Finding{}, nil
}
return nil, fmt.Errorf("reading snapshot %q: %w", path, err)
}
var findings []finding.Finding
if err := json.Unmarshal(data, &findings); err != nil {
return nil, fmt.Errorf("decoding snapshot %q: %w", path, err)
}
if findings == nil {
findings = []finding.Finding{}
}
return findings, nil
}
// Diff computes the set-difference between two snapshots keyed on Finding.Key:
// added is everything in next whose Key isn't in old, removed is everything in
// old whose Key isn't in next. order follows the input slices (added in next's
// order, removed in old's) so output is deterministic for a given pair. a Key
// seen twice in one slice is deduped on first sight, so duplicate findings don't
// double-report.
func Diff(old, next []finding.Finding) (added, removed []finding.Finding) {
oldKeys := make(map[string]struct{}, len(old))
for i := 0; i < len(old); i++ {
oldKeys[old[i].Key] = struct{}{}
}
nextKeys := make(map[string]struct{}, len(next))
for i := 0; i < len(next); i++ {
nextKeys[next[i].Key] = struct{}{}
}
seen := make(map[string]struct{}, len(next))
for i := 0; i < len(next); i++ {
k := next[i].Key
if _, ok := oldKeys[k]; ok {
continue
}
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
added = append(added, next[i])
}
// reuse seen for the removed pass; the two key spaces don't overlap by
// construction (removed keys are absent from next) so a single map is safe.
clear(seen)
for i := 0; i < len(old); i++ {
k := old[i].Key
if _, ok := nextKeys[k]; ok {
continue
}
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
removed = append(removed, old[i])
}
return added, removed
}
+234
View File
@@ -0,0 +1,234 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package store
import (
"os"
"path/filepath"
"reflect"
"sort"
"testing"
"github.com/dropalldatabases/sif/internal/finding"
)
// sampleFindings is a small, stable set of findings reused across the round-trip
// and diff cases; covers two modules and two severities so marshaling exercises
// every Finding field.
func sampleFindings() []finding.Finding {
return []finding.Finding{
{
Target: "https://example.com",
Module: "headers",
Severity: finding.SeverityInfo,
Key: "headers:Server",
Title: "Server",
Raw: "nginx",
},
{
Target: "https://example.com",
Module: "cors",
Severity: finding.SeverityMedium,
Key: "cors:https://example.com:null",
Title: "null origin reflected",
Raw: "allow-origin: null",
},
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
const target = "https://example.com"
want := sampleFindings()
if err := Save(dir, target, want); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := Load(dir, target)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("round-trip mismatch:\n got=%#v\nwant=%#v", got, want)
}
}
func TestSaveCreatesNestedDir(t *testing.T) {
// the state dir need not exist; Save mkdir's it (and parents) lazily.
dir := filepath.Join(t.TempDir(), "nested", "state")
if err := Save(dir, "https://x.test", sampleFindings()); err != nil {
t.Fatalf("Save into missing dir: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat created dir: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected %q to be a directory", dir)
}
}
func TestSaveEmptyDirRejected(t *testing.T) {
if err := Save("", "https://x.test", sampleFindings()); err == nil {
t.Fatal("Save with empty dir: want error, got nil")
}
}
func TestSaveEmptyFindingsRoundTrips(t *testing.T) {
// an empty run is a valid baseline: Save writes [], Load reads back an empty
// (non-nil) slice, never an error.
dir := t.TempDir()
const target = "https://empty.test"
if err := Save(dir, target, nil); err != nil {
t.Fatalf("Save nil findings: %v", err)
}
got, err := Load(dir, target)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got == nil {
t.Fatal("Load returned nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Fatalf("Load returned %d findings, want 0", len(got))
}
}
func TestLoadMissingSnapshotIsEmpty(t *testing.T) {
// no prior run for this target: a missing file is not an error, it's an empty
// baseline so the first run treats everything as added.
dir := t.TempDir()
got, err := Load(dir, "https://never-scanned.test")
if err != nil {
t.Fatalf("Load missing snapshot: %v", err)
}
if got == nil {
t.Fatal("Load returned nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Fatalf("Load missing snapshot returned %d findings, want 0", len(got))
}
}
func TestLoadCorruptSnapshotErrors(t *testing.T) {
// a present-but-garbage snapshot must surface loudly: treating it as empty
// would silently re-flag every finding as new on every run.
dir := t.TempDir()
const target = "https://corrupt.test"
path := filepath.Join(dir, sanitize(target)+snapshotExt)
if err := os.WriteFile(path, []byte("{not json"), snapshotFileMode); err != nil {
t.Fatalf("seeding corrupt snapshot: %v", err)
}
if _, err := Load(dir, target); err == nil {
t.Fatal("Load corrupt snapshot: want error, got nil")
}
}
func TestDiffAddedAndRemoved(t *testing.T) {
base := sampleFindings()
// next drops the cors finding (removed) and adds a takeover (added); the
// headers finding is unchanged and must appear in neither delta.
next := []finding.Finding{
base[0], // headers - unchanged
{
Target: "https://example.com",
Module: "subdomain_takeover",
Severity: finding.SeverityHigh,
Key: "subdomain_takeover:old.example.com",
Title: "takeover: old.example.com",
Raw: "GitHub Pages",
},
}
added, removed := Diff(base, next)
if len(added) != 1 || added[0].Key != "subdomain_takeover:old.example.com" {
t.Fatalf("added = %#v, want the takeover only", added)
}
if len(removed) != 1 || removed[0].Key != "cors:https://example.com:null" {
t.Fatalf("removed = %#v, want the cors finding only", removed)
}
}
func TestDiffNoChange(t *testing.T) {
// identical snapshots produce no delta in either direction.
base := sampleFindings()
added, removed := Diff(base, base)
if len(added) != 0 || len(removed) != 0 {
t.Fatalf("identical snapshots: added=%d removed=%d, want 0/0", len(added), len(removed))
}
}
func TestDiffFirstRunAllAdded(t *testing.T) {
// no prior snapshot (empty old) means every current finding is new.
next := sampleFindings()
added, removed := Diff(nil, next)
if len(removed) != 0 {
t.Fatalf("first run removed=%d, want 0", len(removed))
}
gotKeys := keysOf(added)
wantKeys := keysOf(next)
if !reflect.DeepEqual(gotKeys, wantKeys) {
t.Fatalf("first run added keys=%v, want %v", gotKeys, wantKeys)
}
}
func TestDiffDedupesRepeatedKey(t *testing.T) {
// a Key appearing twice in the new snapshot is reported once, not twice.
f := sampleFindings()[0]
next := []finding.Finding{f, f}
added, _ := Diff(nil, next)
if len(added) != 1 {
t.Fatalf("duplicate key reported %d times, want 1", len(added))
}
}
// keysOf returns the sorted Key set of a finding slice for order-independent
// comparison.
func keysOf(fs []finding.Finding) []string {
out := make([]string, 0, len(fs))
for i := 0; i < len(fs); i++ {
out = append(out, fs[i].Key)
}
sort.Strings(out)
return out
}
func TestSanitizeNoTraversal(t *testing.T) {
// sanitize is the only barrier between an attacker-influenced target and the
// state dir; assert no separator or traversal token survives.
tests := []struct {
in string
want string
}{
{"https://example.com", "https_example_com"},
{"../../etc/passwd", "etc_passwd"},
{"a/b/c", "a_b_c"},
{"....//....//x", "x"},
{"", "target"},
{"///", "target"},
{"host:8443/path?q=1", "host_8443_path_q_1"},
}
for _, tt := range tests {
got := sanitize(tt.in)
if got != tt.want {
t.Errorf("sanitize(%q) = %q, want %q", tt.in, got, tt.want)
}
if filepath.Base(got) != got {
t.Errorf("sanitize(%q) = %q escapes its component", tt.in, got)
}
}
}
+37
View File
@@ -200,6 +200,27 @@ plain output for pipelines: route all chrome to stderr and print one
normalized finding per line to stdout as \fB[severity] target module title\fR.
implies non\-interactive (no spinners).
.TP
.B \-diff
diff mode: snapshot each target's findings to a json file and, on a re\-scan,
print only the delta against the last snapshot (\fB+ new\fR for findings that
appeared, \fB- gone\fR for ones that vanished), then overwrite the snapshot.
the first run for a target reports everything as new.
.TP
.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.
.SH MODULES
@@ -231,6 +252,22 @@ api key used by \fB\-shodan\fR.
.B SECURITYTRAILS_API_KEY
api key used by \fB\-securitytrails\fR.
.TP
.B SLACK_WEBHOOK_URL
slack incoming webhook used by \fB\-notify\fR (yaml key \fBslack_webhook_url\fR).
.TP
.B DISCORD_WEBHOOK_URL
discord webhook used by \fB\-notify\fR (yaml key \fBdiscord_webhook_url\fR).
.TP
.B TELEGRAM_BOT_TOKEN
telegram bot token used by \fB\-notify\fR (yaml key \fBtelegram_api_key\fR);
requires \fBTELEGRAM_CHAT_ID\fR too.
.TP
.B TELEGRAM_CHAT_ID
telegram destination chat used by \fB\-notify\fR (yaml key \fBtelegram_chat_id\fR).
.TP
.B NOTIFY_WEBHOOK_URL
generic json webhook used by \fB\-notify\fR (yaml key \fBwebhook_url\fR).
.TP
.B SIF_NO_PATCHNOTES
set to any value to suppress the once\-per\-version patch note shown at startup.
.SH FILES
+157
View File
@@ -0,0 +1,157 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package sif
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/config"
"github.com/dropalldatabases/sif/internal/finding"
)
// notifyWebhookBody is the generic-webhook wire shape, mirrored here so the
// wiring test can assert which findings crossed the severity floor without
// reaching into the notify package internals.
type notifyWebhookBody struct {
Count int `json:"count"`
Findings []struct {
Severity string `json:"severity"`
Key string `json:"key"`
} `json:"findings"`
}
// mixedSeverityFindings spans the whole ladder so a floor test has something to
// drop on either side of every threshold.
func mixedSeverityFindings() []finding.Finding {
return []finding.Finding{
{Target: "https://t.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:s", Title: "server"},
{Target: "https://t.test", Module: "redirect", Severity: finding.SeverityLow, Key: "redirect:r", Title: "open redirect"},
{Target: "https://t.test", Module: "sql", Severity: finding.SeverityMedium, Key: "sql:e", Title: "db error"},
{Target: "https://t.test", Module: "cors", Severity: finding.SeverityHigh, Key: "cors:c", Title: "reflected origin"},
{Target: "https://t.test", Module: "lfi", Severity: finding.SeverityCritical, Key: "lfi:l", Title: "path traversal"},
}
}
func TestNotifyFindingsSeverityFilter(t *testing.T) {
tests := []struct {
name string
floor string
wantKeys []string
}{
{name: "medium drops info+low", floor: "medium", wantKeys: []string{"sql:e", "cors:c", "lfi:l"}},
{name: "high drops everything below", floor: "high", wantKeys: []string{"cors:c", "lfi:l"}},
{name: "info keeps all", floor: "info", wantKeys: []string{"headers:s", "redirect:r", "sql:e", "cors:c", "lfi:l"}},
{name: "critical keeps only critical", floor: "critical", wantKeys: []string{"lfi:l"}},
// an unrecognized floor must default to medium, not let info through.
{name: "garbage floor defaults medium", floor: "bogus", wantKeys: []string{"sql:e", "cors:c", "lfi:l"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got notifyWebhookBody
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &got); err != nil {
t.Errorf("unmarshal webhook body: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
// route notify at the test server via the generic webhook env var.
t.Setenv("SLACK_WEBHOOK_URL", "")
t.Setenv("DISCORD_WEBHOOK_URL", "")
t.Setenv("TELEGRAM_BOT_TOKEN", "")
t.Setenv("TELEGRAM_CHAT_ID", "")
t.Setenv("NOTIFY_WEBHOOK_URL", srv.URL)
app := &App{settings: &config.Settings{
Notify: true,
NotifySeverity: tt.floor,
Timeout: time.Second,
}}
if err := app.notifyFindings(context.Background(), mixedSeverityFindings()); err != nil {
t.Fatalf("notifyFindings: %v", err)
}
gotKeys := make([]string, 0, len(got.Findings))
for _, f := range got.Findings {
gotKeys = append(gotKeys, f.Key)
}
if !equalStringSets(gotKeys, tt.wantKeys) {
t.Errorf("floor %q delivered keys %v, want %v", tt.floor, gotKeys, tt.wantKeys)
}
if got.Count != len(tt.wantKeys) {
t.Errorf("floor %q count = %d, want %d", tt.floor, got.Count, len(tt.wantKeys))
}
})
}
}
func TestNotifyFindingsBelowFloorIsNoop(t *testing.T) {
// every finding below the floor -> nothing crosses -> no POST at all.
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
t.Setenv("SLACK_WEBHOOK_URL", "")
t.Setenv("DISCORD_WEBHOOK_URL", "")
t.Setenv("TELEGRAM_BOT_TOKEN", "")
t.Setenv("TELEGRAM_CHAT_ID", "")
t.Setenv("NOTIFY_WEBHOOK_URL", srv.URL)
app := &App{settings: &config.Settings{
Notify: true,
NotifySeverity: "critical",
Timeout: time.Second,
}}
infoOnly := []finding.Finding{
{Target: "https://t.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:s", Title: "server"},
}
if err := app.notifyFindings(context.Background(), infoOnly); err != nil {
t.Fatalf("notifyFindings: %v", err)
}
if hit {
t.Fatal("notifyFindings posted with everything below floor, want no-op")
}
}
// equalStringSets reports whether a and b contain the same elements regardless
// of order; the wire order mirrors input order, but order isn't the contract.
func equalStringSets(a, b []string) bool {
if len(a) != len(b) {
return false
}
seen := make(map[string]int, len(a))
for _, s := range a {
seen[s]++
}
for _, s := range b {
seen[s]--
}
for _, n := range seen {
if n != 0 {
return false
}
}
return true
}
+123 -6
View File
@@ -31,12 +31,14 @@ 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"
"github.com/dropalldatabases/sif/internal/scan/builtin"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
jsscan "github.com/dropalldatabases/sif/internal/scan/js"
"github.com/dropalldatabases/sif/internal/store"
)
// App represents the main application structure for sif.
@@ -303,10 +305,22 @@ func (app *App) Run() error {
reportResults := make([]report.Result, 0, 16)
// normalized findings for the whole run; the single Flatten-driven view that
// notify and diff (later) consume. collected alongside the report so both
// describe the same scanners from one pass.
// notify and diff consume. collected alongside the report so both describe the
// same scanners from one pass.
allFindings := make([]finding.Finding, 0, 16)
// resolve the snapshot dir once when diff mode is on; a bad default isn't
// fatal - diff just no-ops for the run rather than killing the scan.
storeDir := ""
if app.settings.Diff {
dir, err := app.resolveStoreDir()
if err != nil {
log.Warnf("diff disabled: %v", err)
} else {
storeDir = dir
}
}
for _, url := range app.targets {
output.Info("Starting scan on %s", output.Highlight.Render(url))
@@ -664,7 +678,17 @@ func (app *App) Run() error {
fmt.Println(string(marshalled))
}
allFindings = append(allFindings, collectFindings(url, moduleResults)...)
targetFindings := collectFindings(url, moduleResults)
allFindings = append(allFindings, targetFindings...)
// diff mode is per-target: load this target's last snapshot, surface only
// the delta, then overwrite the snapshot so the next run diffs against now.
// storeDir is "" when diff is off or the dir couldn't resolve, in which
// case this is a no-op and behavior is unchanged.
if storeDir != "" {
app.diffTarget(storeDir, url, targetFindings)
}
// the report carries raw blobs and is only built when an export flag is
// set, so the common path skips the marshalling entirely.
if wantReport {
@@ -676,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.
@@ -709,9 +741,9 @@ func printFindings(findings []finding.Finding) {
}
// collectFindings normalizes one target's module results through finding.Flatten
// - the single normalization path that notify and diff (later bundles) build on.
// every scan result struct collapses to flat, severity-ranked findings here so a
// scanner is described once, not once per consumer.
// - the single normalization path that notify and diff build on. every scan
// result struct collapses to flat, severity-ranked findings here so a scanner is
// described once, not once per consumer.
func collectFindings(target string, moduleResults []ModuleResult) []finding.Finding {
out := make([]finding.Finding, 0, len(moduleResults))
for _, mr := range moduleResults {
@@ -720,6 +752,66 @@ func collectFindings(target string, moduleResults []ModuleResult) []finding.Find
return out
}
// resolveStoreDir picks the snapshot directory for diff mode. precedence: an
// explicit -store wins; else the run's log dir is reused (snapshots live next to
// logs); else the per-user default under <user-config>/sif/state. returns an
// error only when no usable location exists, so the caller can disable diff
// without failing the scan.
func (app *App) resolveStoreDir() (string, error) {
if app.settings.Store != "" {
return app.settings.Store, nil
}
if app.settings.LogDir != "" {
return app.settings.LogDir, nil
}
dir, err := store.DefaultDir()
if err != nil {
return "", fmt.Errorf("resolving snapshot dir: %w", err)
}
return dir, nil
}
// diffTarget loads target's previous snapshot, prints the added/removed delta
// against the current findings, then overwrites the snapshot so the next run
// diffs against this one. a load failure surfaces but doesn't abort the run -
// the new snapshot is still written so a corrupt baseline self-heals. always
// saves, even when the delta is empty, to advance the baseline.
func (app *App) diffTarget(dir, target string, current []finding.Finding) {
previous, err := store.Load(dir, target)
if err != nil {
log.Warnf("diff: reading snapshot for %s, treating as fresh: %v", target, err)
previous = nil
}
added, removed := store.Diff(previous, current)
printDiff(target, added, removed)
if err := store.Save(dir, target, current); err != nil {
log.Warnf("diff: saving snapshot for %s: %v", target, err)
}
}
// printDiff renders a target's diff: each added finding marked "+ new", each
// removed one "- gone", with a one-line note when nothing changed. routed
// through the shared output sink so -silent keeps it on stderr alongside the
// other chrome. a single Builder keeps the block from interleaving.
func printDiff(target string, added, removed []finding.Finding) {
if len(added) == 0 && len(removed) == 0 {
output.Info("diff %s: no changes since last snapshot", target)
return
}
var b strings.Builder
fmt.Fprintf(&b, "diff %s: %d new, %d gone\n", target, len(added), len(removed))
for i := 0; i < len(added); i++ {
fmt.Fprintf(&b, " + new %s\n", added[i].Line())
}
for i := 0; i < len(removed); i++ {
fmt.Fprintf(&b, " - gone %s\n", removed[i].Line())
}
fmt.Fprint(output.Writer(), b.String())
}
// collectReportResults flattens one target's module results into the report
// model, carrying each finding as raw json so the report package stays free of
// scan types. a result that won't marshal is skipped rather than failing the run.
@@ -762,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 {
+70
View File
@@ -20,6 +20,7 @@ import (
"github.com/dropalldatabases/sif/internal/config"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/store"
)
// TestMain neutralizes the stdin seam for the whole package so tests that build
@@ -373,3 +374,72 @@ func TestUrlResult_JSON(t *testing.T) {
t.Errorf("UrlResult.Results = %d, want 1", len(ur.Results))
}
}
func TestResolveStoreDir(t *testing.T) {
// explicit -store wins over everything.
explicit := &App{settings: &config.Settings{Store: "/tmp/snaps", LogDir: "/tmp/logs"}}
if dir, err := explicit.resolveStoreDir(); err != nil || dir != "/tmp/snaps" {
t.Fatalf("explicit store: got (%q, %v), want (/tmp/snaps, nil)", dir, err)
}
// no -store: reuse the log dir.
logged := &App{settings: &config.Settings{LogDir: "/tmp/logs"}}
if dir, err := logged.resolveStoreDir(); err != nil || dir != "/tmp/logs" {
t.Fatalf("log dir fallback: got (%q, %v), want (/tmp/logs, nil)", dir, err)
}
// neither set: fall through to the per-user default (non-empty, no error).
bare := &App{settings: &config.Settings{}}
dir, err := bare.resolveStoreDir()
if err != nil {
t.Fatalf("default store dir: %v", err)
}
if dir == "" {
t.Fatal("default store dir resolved empty")
}
}
func TestDiffTargetSnapshotsAndDiffs(t *testing.T) {
dir := t.TempDir()
const target = "https://diff.example.com"
app := &App{settings: &config.Settings{Diff: true, Store: dir}}
first := []finding.Finding{
{Target: target, Module: "headers", Severity: finding.SeverityInfo, Key: "headers:Server", Title: "Server", Raw: "nginx"},
}
// first run: no prior snapshot, everything is new; the snapshot must persist.
app.diffTarget(dir, target, first)
saved, err := store.Load(dir, target)
if err != nil {
t.Fatalf("load after first run: %v", err)
}
if len(saved) != 1 || saved[0].Key != "headers:Server" {
t.Fatalf("snapshot after first run = %#v, want the headers finding", saved)
}
// second run with a different set: the snapshot must advance to the new set so
// a third run would diff against it.
second := []finding.Finding{
{Target: target, Module: "cors", Severity: finding.SeverityMedium, Key: "cors:x", Title: "null origin", Raw: "null"},
}
app.diffTarget(dir, target, second)
saved, err = store.Load(dir, target)
if err != nil {
t.Fatalf("load after second run: %v", err)
}
if len(saved) != 1 || saved[0].Key != "cors:x" {
t.Fatalf("snapshot after second run = %#v, want the cors finding", saved)
}
// the delta between the two snapshots is exactly: headers gone, cors new.
added, removed := store.Diff(first, second)
if len(added) != 1 || added[0].Key != "cors:x" {
t.Fatalf("added = %#v, want cors:x", added)
}
if len(removed) != 1 || removed[0].Key != "headers:Server" {
t.Fatalf("removed = %#v, want headers:Server", removed)
}
}