Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,14 @@ slack:
slack_channel: "recon"
slack_username: "test"
slack_format: "{{data}}"
slack_icon_emoji: ":ghost:"
slack_webhook_url: "https://hooks.slack.com/services/XXXXXX"

- id: "vulns"
slack_channel: "vulns"
slack_username: "test"
slack_format: "{{data}}"
slack_icon_emoji: ":ghost:"
slack_webhook_url: "https://hooks.slack.com/services/XXXXXX"

discord:
Expand All @@ -112,7 +114,7 @@ discord:
telegram:
- id: "tel"
telegram_api_key: "XXXXXXXXXXXX"
telegram_chat_id: "XXXXXXXX"
telegram_chat_id: "XXXXXXXX" # Optional topic id XXXXXXXX:Y
telegram_format: "{{data}}"
telegram_parsemode: "Markdown" # None/Markdown/MarkdownV2/HTML (https://core.telegram.org/bots/api#formatting-options)

Expand Down
38 changes: 36 additions & 2 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ type Runner struct {
func NewRunner(options *types.Options) (*Runner, error) {
var providerOptions providers.ProviderOptions

if options.ProviderConfig == "" {
usingDefaultPath := options.ProviderConfig == ""
if usingDefaultPath {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
Expand All @@ -41,13 +42,46 @@ func NewRunner(options *types.Options) (*Runner, error) {
gologger.Print().Msgf("Using default provider config: %s\n", options.ProviderConfig)
}

if _, statErr := os.Stat(options.ProviderConfig); os.IsNotExist(statErr) {
if !usingDefaultPath {
// User explicitly specified a config path that does not exist — surface an error.
return nil, errors.Errorf("provider config file not found: %s", options.ProviderConfig)
}
// Default path does not exist yet: create directory and write a commented template
// so the user knows what to fill in. Avoids a fatal error on first run.
if mkdirErr := os.MkdirAll(filepath.Dir(options.ProviderConfig), 0700); mkdirErr != nil {
return nil, errors.Wrap(mkdirErr, "could not create provider config directory")
}
defaultConfig := `# notify provider configuration
# Fill in your provider details below. Full documentation:
# https://docs.projectdiscovery.io/tools/notify/provider-config
#
# Example:
# slack:
# - id: "my-slack"
# slack_webhook_url: "https://hooks.slack.com/services/..."
# slack_username: "notify"
# slack_format: "{{data}}"
`
if writeErr := os.WriteFile(options.ProviderConfig, []byte(defaultConfig), 0600); writeErr != nil {
gologger.Warning().Msgf("Could not create default provider config at %s: %s\n", options.ProviderConfig, writeErr)
} else {
gologger.Info().Msgf("Created default provider config at %s — please add your provider credentials.\n", options.ProviderConfig)
}
}

reader, err := fileutil.SubstituteConfigFromEnvVars(options.ProviderConfig)
if err != nil {
return nil, err
}

// Decode may return io.EOF when the config file is empty or contains only comments.
// In that case providerOptions remains zero-valued, which is safe — the providers
// client will simply have no channels configured.
if parseErr := yaml.NewDecoder(reader).Decode(&providerOptions); parseErr != nil {
return nil, errors.Wrap(parseErr, "could not parse provider config file")
if !errors.Is(parseErr, io.EOF) {
return nil, errors.Wrap(parseErr, "could not parse provider config file")
}
}

shoutrrr.SetLogger(log.New(io.Discard, "", 0))
Expand Down
84 changes: 84 additions & 0 deletions internal/runner/runner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package runner

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/projectdiscovery/notify/pkg/types"
)

// On first run with no -pc flag, NewRunner should create the default provider
// config (directory + commented template) instead of failing fatally (#487).
func TestNewRunner_FirstRunCreatesDefaultProviderConfig(t *testing.T) {
tmpHome := t.TempDir()
// os.UserHomeDir uses $HOME on unix and %USERPROFILE% on Windows.
t.Setenv("HOME", tmpHome)
t.Setenv("USERPROFILE", tmpHome)

options := &types.Options{} // empty ProviderConfig => default path
runner, err := NewRunner(options)
if err != nil {
t.Fatalf("NewRunner returned error on first run: %v", err)
}
if runner == nil {
t.Fatal("NewRunner returned nil runner")
}

want := filepath.Join(tmpHome, types.DefaultProviderConfigLocation)
if options.ProviderConfig != want {
t.Fatalf("ProviderConfig = %q, want %q", options.ProviderConfig, want)
}

info, statErr := os.Stat(options.ProviderConfig)
if statErr != nil {
t.Fatalf("default provider config was not created: %v", statErr)
}
if runtime.GOOS != "windows" {
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("config file perms = %o, want 0600", perm)
}
}
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add directory permission check to validate 0700 on the parent directory.

The implementation creates the parent directory with 0700 permissions (as shown in context snippet runner.go:54), but the test only verifies file permissions. Directory permissions should also be validated since they're part of the security posture.

🔒 Proposed addition to verify directory permissions
 	if runtime.GOOS != "windows" {
 		if perm := info.Mode().Perm(); perm != 0o600 {
 			t.Errorf("config file perms = %o, want 0600", perm)
 		}
+		dirInfo, dirStatErr := os.Stat(filepath.Dir(options.ProviderConfig))
+		if dirStatErr != nil {
+			t.Fatalf("stat config directory: %v", dirStatErr)
+		}
+		if dirPerm := dirInfo.Mode().Perm(); dirPerm != 0o700 {
+			t.Errorf("config directory perms = %o, want 0700", dirPerm)
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if runtime.GOOS != "windows" {
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("config file perms = %o, want 0600", perm)
}
}
if runtime.GOOS != "windows" {
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("config file perms = %o, want 0600", perm)
}
dirInfo, dirStatErr := os.Stat(filepath.Dir(options.ProviderConfig))
if dirStatErr != nil {
t.Fatalf("stat config directory: %v", dirStatErr)
}
if dirPerm := dirInfo.Mode().Perm(); dirPerm != 0o700 {
t.Errorf("config directory perms = %o, want 0700", dirPerm)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runner/runner_test.go` around lines 39 - 43, The test currently only
validates file permissions (0600) for the config file but does not verify the
parent directory permissions (0700) which are created in runner.go. Within the
same conditional block checking runtime.GOOS != "windows", add an additional
permission check for the parent directory by obtaining its file info (similar to
how you get info for the file) and verifying that the directory mode permissions
equal 0o700, logging an error if they do not match.


data, readErr := os.ReadFile(options.ProviderConfig)
if readErr != nil {
t.Fatalf("read created config: %v", readErr)
}
if !strings.Contains(string(data), "notify provider configuration") {
t.Errorf("created config missing template banner, got:\n%s", data)
}
}

// An explicitly supplied -pc path that does not exist must error, not silently
// create a template.
func TestNewRunner_ExplicitMissingConfigReturnsError(t *testing.T) {
missing := filepath.Join(t.TempDir(), "nope", "provider-config.yaml")
_, err := NewRunner(&types.Options{ProviderConfig: missing})
if err == nil {
t.Fatal("expected error for missing explicit config path, got nil")
}
if !strings.Contains(err.Error(), "provider config file not found") {
t.Fatalf("unexpected error: %v", err)
}
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
t.Error("explicit missing path should not have been created")
}
}

// An empty / comment-only config (e.g. the freshly created template) must not
// block startup.
func TestNewRunner_CommentOnlyConfigIsTolerated(t *testing.T) {
path := filepath.Join(t.TempDir(), "provider-config.yaml")
if err := os.WriteFile(path, []byte("# only comments, no providers\n"), 0o600); err != nil {
t.Fatal(err)
}
runner, err := NewRunner(&types.Options{ProviderConfig: path})
if err != nil {
t.Fatalf("comment-only config should be tolerated, got: %v", err)
}
if runner == nil {
t.Fatal("NewRunner returned nil runner")
}
}
46 changes: 36 additions & 10 deletions pkg/providers/slack/slack.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package slack

import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"net/http"
"strings"

"github.com/containrrr/shoutrrr"
"github.com/pkg/errors"
"go.uber.org/multierr"

Expand All @@ -28,6 +29,7 @@ type Options struct {
SlackThreadTS string `yaml:"slack_thread_ts,omitempty"`
SlackToken string `yaml:"slack_token,omitempty"`
SlackFormat string `yaml:"slack_format,omitempty"`
SlackIconEmoji string `yaml:"slack_icon_emoji,omitempty"`
}

func New(options []*Options, ids []string) (*Provider, error) {
Expand All @@ -50,6 +52,7 @@ func (p *Provider) Send(message, CliFormat string) error {
for _, pr := range p.Slack {
msg := utils.FormatMessage(message, utils.SelectFormat(CliFormat, pr.SlackFormat), p.counter)

// Handle threaded messages separately
if pr.SlackThreads {
if pr.SlackToken == "" {
err := errors.Wrap(fmt.Errorf("slack_token value is required to start a thread"),
Expand All @@ -70,22 +73,45 @@ func (p *Provider) Send(message, CliFormat string) error {
continue
}
} else {
slackTokens := strings.TrimPrefix(pr.SlackWebHookURL, "https://hooks.slack.com/services/")
url := &url.URL{
Scheme: "slack",
Path: slackTokens,
// Send via webhook with emoji and username
if !strings.HasPrefix(pr.SlackWebHookURL, "https://hooks.slack.com/services/") {
err := errors.Wrap(fmt.Errorf("invalid slack webhook URL"),
fmt.Sprintf("failed to send slack notification for id: %s ", pr.ID))
SlackErr = multierr.Append(SlackErr, err)
continue
}

err := shoutrrr.Send(url.String(), msg)
payload := map[string]interface{}{
"text": msg,
}
if pr.SlackUsername != "" {
payload["username"] = pr.SlackUsername
}
if pr.SlackIconEmoji != "" {
payload["icon_emoji"] = pr.SlackIconEmoji
}

jsonPayload, err := json.Marshal(payload)
if err != nil {
err = errors.Wrap(err,
fmt.Sprintf("failed to send slack notification for id: %s ", pr.ID))
err = errors.Wrap(err, fmt.Sprintf("failed to marshal Slack payload for id: %s", pr.ID))
SlackErr = multierr.Append(SlackErr, err)
continue
}

resp, err := http.Post(pr.SlackWebHookURL, "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("failed to send slack notification for id: %s", pr.ID))
SlackErr = multierr.Append(SlackErr, err)
continue
}
resp.Body.Close()
if resp.StatusCode >= 400 {
err = errors.Wrap(fmt.Errorf("received non-success status: %s", resp.Status), fmt.Sprintf("failed to send slack notification for id: %s", pr.ID))
SlackErr = multierr.Append(SlackErr, err)
continue
}
}
gologger.Verbose().Msgf("Slack notification sent for id: %s", pr.ID)

}
return SlackErr
}
Loading