diff --git a/.env.example b/.env.example index e8a55a4..ba3f2b7 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ BUGCROWD_OTP=your_totp_secret # API Authentication API_KEY=your_api_key_here + +# Webhook Notifications (optional, referenced from config/settings.yml as ${WEBHOOK_TOKEN}) +WEBHOOK_TOKEN=your_webhook_token diff --git a/.rubocop.yml b/.rubocop.yml index 935dfd3..8af4f7b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -26,7 +26,7 @@ Metrics/ClassLength: Max: 200 Metrics/ModuleLength: - Max: 120 + Max: 140 Metrics/MethodLength: Max: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3477b..1fd6916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## Version 2.1.0 - Generic HTTP Webhook Notifications + +### ✨ Features + +#### Generic HTTP Webhook Notifications + +Notifications are no longer Discord-only: a generic HTTP webhook notifier can be enabled alongside (or instead of) Discord. + +**Changes:** +- **New `Notifiers::Webhook`**: POSTs every event as JSON to a configurable endpoint +- **New `Notifiers::Multi`**: fans notifications out to all configured notifiers, a failing one never blocks the others +- **Custom headers**: optional headers (typically for authentication) with `${VAR}` environment variable interpolation, so secrets stay in `.env` +- **Same filtering as Discord**: `events` and `new_scope_types` filters, plus a dedicated `error` event + +**Configuration** (`config/settings.yml`): + +```yaml +webhook: + enabled: true + url: "https://example.com/hooks/scopes" + headers: + Authorization: "Bearer ${WEBHOOK_TOKEN}" + events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset", "error"] + new_scope_types: ["web"] +``` + +**Payload:** + +```json +{ + "event": "new_scope", + "timestamp": "2025-07-30T10:17:00Z", + "data": { "platform": "yeswehack", "program": "Example Program", "value": "*.example.com", "type": "web" } +} +``` + +--- + ## Version 2.0.1 - Bugcrowd Auth Fix ### 🐛 Bug Fixes diff --git a/README.md b/README.md index 2e37942..51e9908 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Image](https://github.com/user-attachments/assets/8fa9dd2a-04c8-48d4-a0d7-6057c102436c) -A tool to automatically synchronize and track bug bounty program scopes from multiple platforms. Monitor new programs, scope changes, and receive Discord notifications for updates. +A tool to automatically synchronize and track bug bounty program scopes from multiple platforms. Monitor new programs, scope changes, and receive Discord or HTTP webhook notifications for updates. [![Ruby](https://img.shields.io/badge/Ruby-3.4.7-red.svg)](https://www.ruby-lang.org/en/) [![Docker](https://img.shields.io/badge/Docker-Supported-blue.svg)](https://www.docker.com/) @@ -17,7 +17,7 @@ A tool to automatically synchronize and track bug bounty program scopes from mul - 🔄 **Multi-Platform Support**: YesWeHack, HackerOne, Intigriti, Bugcrowd - 📊 **Automatic Synchronization**: Continuously monitor programs and detect scope changes -- 🔔 **Discord Notifications**: Get notified about new programs, scope changes, and removals +- 🔔 **Notifications**: Get notified about new programs, scope changes, and removals via Discord or a generic HTTP webhook (JSON) - 🗄️ **SQLite Database**: Persistent storage with historical change tracking - 🌐 **REST API**: Query scopes and changes programmatically - 🎯 **Smart Scope Processing**: Automatic validation and normalization with platform-specific rules @@ -39,7 +39,7 @@ A tool to automatically synchronize and track bug bounty program scopes from mul 2. **Configure platform credentials** in `.env` (email, password, API tokens, TOTP secrets) -3. **Configure application settings** in `config/settings.yml` (enable/disable platforms, Discord webhooks, etc.) +3. **Configure application settings** in `config/settings.yml` (enable/disable platforms, notification webhooks, etc.) ### Using Docker (Recommended) @@ -383,9 +383,10 @@ curl -H "X-API-KEY: your_api_key" "http://localhost:4567/exclusions" -## Discord Notifications +## Notifications -Configure Discord webhooks to receive real-time notifications: +Two notifiers are available and can be enabled independently (both at once if needed): +Discord webhooks and a generic HTTP webhook receiving JSON payloads. ### Notification Types @@ -394,10 +395,11 @@ Configure Discord webhooks to receive real-time notifications: - **new_scope**: New scope added to a program - **removed_scope**: Scope removed from a program - **ignored_asset**: Asset failed validation and was ignored +- **error**: Synchronization error (HTTP webhook only, Discord uses a dedicated `errors` webhook) ### Scope Type Filtering -Use `new_scope_types` to filter which scope types trigger notifications: +Use `new_scope_types` to filter which scope types trigger `new_scope` notifications: ```yaml discord: @@ -407,6 +409,83 @@ discord: # Or leave empty/null to notify for all types ``` +### Discord Notifications + +```yaml +discord: + enabled: true + webhooks: + main: + url: "https://discord.com/api/webhooks/.../xxx" + events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset"] + new_scope_types: ["web"] + errors: + url: "https://discord.com/api/webhooks/.../yyy" +``` + +### HTTP Webhook Notifications + +Send every event as a JSON `POST` to your own endpoint: + +```yaml +webhook: + enabled: true + url: "https://example.com/hooks/scopes" + headers: + Authorization: "Bearer ${WEBHOOK_TOKEN}" # ${VAR} is replaced by the environment variable + events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset", "error"] + new_scope_types: ["web"] +``` + +Custom headers are optional and mainly meant for authentication. Any `${VAR}` inside a header +value (or inside the URL) is replaced by the matching environment variable, so secrets stay in +`.env` instead of `config/settings.yml`. + +#### Payload Format + +Every request shares the same envelope, `data` depends on the event: + +```json +{ + "event": "new_scope", + "timestamp": "2025-07-30T10:17:00Z", + "data": { + "platform": "yeswehack", + "program": "Example Program", + "value": "*.example.com", + "type": "web" + } +} +``` + +
+Payload per event + +| Event | `data` fields | +|-------|---------------| +| `new_program` | `platform`, `program`, `slug`, `scopes_count`, `scopes` (count per type) | +| `removed_program` | `platform`, `program`, `slug` | +| `new_scope` | `platform`, `program`, `value`, `type` | +| `removed_scope` | `platform`, `program`, `value` | +| `ignored_asset` | `platform`, `program`, `value`, `reason` | +| `error` | `title`, `message` | + +```json +{ + "event": "new_program", + "timestamp": "2025-07-30T10:17:00Z", + "data": { + "platform": "yeswehack", + "program": "Example Program", + "slug": "example-program", + "scopes_count": 3, + "scopes": { "web": 2, "mobile": 1 } + } +} +``` + +
+ ## Scope Processing ScopesExtractor includes intelligent scope processing with automatic normalization and validation. @@ -531,7 +610,7 @@ scopes_refactor/ │ ├── sync_manager.rb # Platform synchronization orchestration │ ├── validator.rb # Scope validation logic │ ├── models/ # Dry-Struct models -│ ├── notifiers/ # Discord notifications +│ ├── notifiers/ # Discord & HTTP webhook notifications │ └── platforms/ # Platform-specific implementations │ ├── base_platform.rb │ ├── yeswehack/ diff --git a/config/settings.yml b/config/settings.yml index 6a74a1e..c6e58cc 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -52,6 +52,16 @@ discord: errors: url: "" # https://discord.com/api/webhooks/.../yyy +# Generic HTTP webhook notifications (JSON payloads) +webhook: + enabled: false + url: "" # https://example.com/hooks/scopes + headers: {} # Optional custom headers, "${VAR}" is replaced by the environment variable + # Example: { "Authorization": "Bearer ${WEBHOOK_TOKEN}" } + events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset", "error"] + new_scope_types: ["web"] # Filter new_scope notifications by type (web, mobile, android, ios, etc.) + # Empty or null = notify all types + platform_exclusions: yeswehack: [] hackerone: [] diff --git a/lib/scopes_extractor.rb b/lib/scopes_extractor.rb index 5cf1504..7185877 100644 --- a/lib/scopes_extractor.rb +++ b/lib/scopes_extractor.rb @@ -66,6 +66,8 @@ def colorize_severity(severity) # Require notifiers require_relative 'scopes_extractor/notifiers/discord' +require_relative 'scopes_extractor/notifiers/webhook' +require_relative 'scopes_extractor/notifiers/multi' # Require models require_relative 'scopes_extractor/models/scope' diff --git a/lib/scopes_extractor/config.rb b/lib/scopes_extractor/config.rb index 1f1ccc1..c0f5ac6 100644 --- a/lib/scopes_extractor/config.rb +++ b/lib/scopes_extractor/config.rb @@ -131,6 +131,32 @@ def discord_new_scope_types discord_main_webhook[:new_scope_types] || [] end + def webhook + load[:webhook] || {} + end + + def webhook_enabled? + webhook[:enabled] == true + end + + def webhook_url + resolve_env(webhook[:url]) + end + + # Custom headers sent with every webhook call (e.g. authentication). + # Values may reference environment variables using the "${VAR}" syntax. + def webhook_headers + (webhook[:headers] || {}).transform_keys(&:to_s).transform_values { |value| resolve_env(value) } + end + + def webhook_events + webhook[:events] || [] + end + + def webhook_new_scope_types + webhook[:new_scope_types] || [] + end + def platform_exclusions load[:platform_exclusions] || {} end @@ -143,6 +169,10 @@ def excluded?(platform_name, program_slug) private + def resolve_env(value) + value.to_s.gsub(/\$\{(\w+)\}/) { ENV.fetch(Regexp.last_match(1), '') } + end + def config_path File.join(ScopesExtractor.root, 'config', 'settings.yml') end diff --git a/lib/scopes_extractor/diff_engine.rb b/lib/scopes_extractor/diff_engine.rb index 633aeaa..0a2876f 100644 --- a/lib/scopes_extractor/diff_engine.rb +++ b/lib/scopes_extractor/diff_engine.rb @@ -5,7 +5,7 @@ module ScopesExtractor class DiffEngine def initialize(notifier: nil) @db = ScopesExtractor.db - @notifier = notifier || Notifiers::Discord.new + @notifier = notifier || Notifiers::Multi.default end def process_program(platform_name, fetched_program, skip_notifications: false) @@ -45,7 +45,7 @@ def process_program(platform_name, fetched_program, skip_notifications: false) details: 'Brand new program discovered' ) - # Only send Discord notification if not skipping notifications + # Only send notifications if not skipping them return if skip_notifications @notifier.notify_new_program( diff --git a/lib/scopes_extractor/notifiers/multi.rb b/lib/scopes_extractor/notifiers/multi.rb new file mode 100644 index 0000000..88af707 --- /dev/null +++ b/lib/scopes_extractor/notifiers/multi.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ScopesExtractor + module Notifiers + # Fans out every notification to all configured notifiers. + # A failing notifier never prevents the others from being notified. + class Multi + NOTIFY_METHODS = %i[ + notify_new_program notify_removed_program notify_new_scope + notify_removed_scope notify_ignored_asset notify_error + ].freeze + + # Default set of notifiers, each one self-disables when not configured. + def self.default + new([Discord.new, Webhook.new]) + end + + def initialize(notifiers) + @notifiers = notifiers + end + + NOTIFY_METHODS.each do |method_name| + define_method(method_name) do |*args, **kwargs| + @notifiers.each do |notifier| + notifier.public_send(method_name, *args, **kwargs) + rescue StandardError => e + ScopesExtractor.logger.error "#{notifier.class} ##{method_name} failed: #{e.message}" + end + end + end + end + end +end diff --git a/lib/scopes_extractor/notifiers/webhook.rb b/lib/scopes_extractor/notifiers/webhook.rb new file mode 100644 index 0000000..43c75c7 --- /dev/null +++ b/lib/scopes_extractor/notifiers/webhook.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'json' +require 'time' + +module ScopesExtractor + module Notifiers + # Sends notifications as JSON payloads to a generic HTTP endpoint. + # + # Every payload shares the same envelope: + # { "event": "", "timestamp": "", "data": { ... } } + # + # Custom headers (e.g. an Authorization header) can be declared in the + # configuration and may reference environment variables as "${VAR}". + class Webhook + DEFAULT_HEADERS = { 'Content-Type' => 'application/json' }.freeze + + def initialize + @url = Config.webhook_url + @headers = DEFAULT_HEADERS.merge(Config.webhook_headers) + @events = Config.webhook_events + @new_scope_types = Config.webhook_new_scope_types + end + + def notify_new_program(platform, program_name, slug, scope_stats: {}) + publish('new_program', platform: platform, program: program_name, slug: slug, + scopes_count: scope_stats.values.sum, scopes: stringify_stats(scope_stats)) + end + + def notify_removed_program(platform, program_name, slug) + publish('removed_program', platform: platform, program: program_name, slug: slug) + end + + def notify_new_scope(platform, program_name, value, type) + return unless scope_type_enabled?(type) + + publish('new_scope', platform: platform, program: program_name, value: value, type: type) + end + + def notify_removed_scope(platform, program_name, value) + publish('removed_scope', platform: platform, program: program_name, value: value) + end + + def notify_ignored_asset(platform, program_name, value, reason) + publish('ignored_asset', platform: platform, program: program_name, value: value, reason: reason) + end + + def notify_error(title, message) + publish('error', title: title, message: message) + end + + private + + def enabled? + Config.webhook_enabled? && !@url.to_s.empty? + end + + def event_enabled?(event_name) + @events.empty? || @events.include?(event_name) + end + + def scope_type_enabled?(type) + @new_scope_types.empty? || @new_scope_types.include?(type.to_s) + end + + def stringify_stats(scope_stats) + scope_stats.transform_keys(&:to_s) + end + + def publish(event, **data) + return unless enabled? && event_enabled?(event) + + payload = { event: event, timestamp: Time.now.utc.iso8601, data: data } + response = HTTP.post(@url, body: payload.to_json, headers: @headers) + + return if (200..299).cover?(response.code) + + ScopesExtractor.logger.error "Webhook notification failed: #{response.code} - #{response.body}" + rescue StandardError => e + ScopesExtractor.logger.error "Webhook notification error: #{e.message}" + end + end + end +end diff --git a/lib/scopes_extractor/sync_manager.rb b/lib/scopes_extractor/sync_manager.rb index 9297dc7..9245b3d 100644 --- a/lib/scopes_extractor/sync_manager.rb +++ b/lib/scopes_extractor/sync_manager.rb @@ -6,7 +6,7 @@ module ScopesExtractor class SyncManager def initialize(diff_engine: nil, notifier: nil) @diff_engine = diff_engine || DiffEngine.new(notifier: notifier) - @notifier = notifier || Notifiers::Discord.new + @notifier = notifier || Notifiers::Multi.default @platforms = [] @db = ScopesExtractor.db setup_platforms diff --git a/spec/scopes_extractor/config_spec.rb b/spec/scopes_extractor/config_spec.rb index 19c4923..11a76b4 100644 --- a/spec/scopes_extractor/config_spec.rb +++ b/spec/scopes_extractor/config_spec.rb @@ -161,6 +161,44 @@ end end + describe 'webhook configuration' do + describe '.webhook_enabled?' do + it 'is disabled by default' do + expect(described_class.webhook_enabled?).to be false + end + end + + describe '.webhook_url' do + it 'resolves environment variables in the url' do + allow(described_class).to receive(:webhook).and_return({ url: 'https://example.com/${WEBHOOK_PATH}' }) + allow(ENV).to receive(:fetch).with('WEBHOOK_PATH', '').and_return('hooks') + + expect(described_class.webhook_url).to eq('https://example.com/hooks') + end + end + + describe '.webhook_headers' do + it 'returns an empty hash when none are configured' do + allow(described_class).to receive(:webhook).and_return({}) + + expect(described_class.webhook_headers).to eq({}) + end + + it 'stringifies keys and resolves environment variables in values' do + allow(described_class).to receive(:webhook).and_return({ headers: { Authorization: 'Bearer ${WEBHOOK_TOKEN}' } }) + allow(ENV).to receive(:fetch).with('WEBHOOK_TOKEN', '').and_return('s3cret') + + expect(described_class.webhook_headers).to eq('Authorization' => 'Bearer s3cret') + end + + it 'resolves unknown environment variables to an empty string' do + allow(described_class).to receive(:webhook).and_return({ headers: { 'X-Token' => '${MISSING_VAR}' } }) + + expect(described_class.webhook_headers).to eq('X-Token' => '') + end + end + end + describe 'platform exclusions' do describe '.excluded?' do it 'returns false when program is not in exclusion list' do diff --git a/spec/scopes_extractor/notifiers/multi_spec.rb b/spec/scopes_extractor/notifiers/multi_spec.rb new file mode 100644 index 0000000..9d82fe3 --- /dev/null +++ b/spec/scopes_extractor/notifiers/multi_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ScopesExtractor::Notifiers::Multi do + subject(:notifier) { described_class.new([first, second]) } + + let(:first) { double('Notifier') } + let(:second) { double('Notifier') } + + it 'forwards notifications to every notifier' do + expect(first).to receive(:notify_new_scope).with('yeswehack', 'Program', 'example.com', 'web') + expect(second).to receive(:notify_new_scope).with('yeswehack', 'Program', 'example.com', 'web') + + notifier.notify_new_scope('yeswehack', 'Program', 'example.com', 'web') + end + + it 'forwards keyword arguments' do + expect(first).to receive(:notify_new_program).with('yeswehack', 'Program', 'slug', scope_stats: { 'web' => 1 }) + expect(second).to receive(:notify_new_program).with('yeswehack', 'Program', 'slug', scope_stats: { 'web' => 1 }) + + notifier.notify_new_program('yeswehack', 'Program', 'slug', scope_stats: { 'web' => 1 }) + end + + it 'keeps notifying the others when one fails' do + allow(first).to receive(:notify_error).and_raise(StandardError, 'boom') + expect(second).to receive(:notify_error).with('Sync Error', 'boom') + expect(ScopesExtractor.logger).to receive(:error).with(/notify_error failed: boom/) + + expect { notifier.notify_error('Sync Error', 'boom') }.not_to raise_error + end + + describe '.default' do + it 'builds the Discord and Webhook notifiers' do + expect(described_class.default).to be_a(described_class) + end + end +end diff --git a/spec/scopes_extractor/notifiers/webhook_spec.rb b/spec/scopes_extractor/notifiers/webhook_spec.rb new file mode 100644 index 0000000..e991f06 --- /dev/null +++ b/spec/scopes_extractor/notifiers/webhook_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ScopesExtractor::Notifiers::Webhook do + subject(:notifier) { described_class.new } + + let(:url) { 'https://example.com/hooks/scopes' } + let(:response) { double(code: 200, body: '') } + + before do + allow(ScopesExtractor::Config).to receive_messages( + webhook_enabled?: true, + webhook_url: url, + webhook_headers: {}, + webhook_events: [], + webhook_new_scope_types: [] + ) + allow(ScopesExtractor::HTTP).to receive(:post).and_return(response) + end + + # Captures the JSON body sent to the endpoint + def sent_payload + body = nil + allow(ScopesExtractor::HTTP).to receive(:post) do |_url, options| + body = options[:body] + response + end + yield + body && JSON.parse(body) + end + + describe '#notify_new_program' do + it 'posts a JSON payload describing the event' do + payload = sent_payload do + notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug', scope_stats: { 'web' => 2 }) + end + + expect(payload).to include('event' => 'new_program') + expect(payload['timestamp']).to match(/\A\d{4}-\d{2}-\d{2}T/) + expect(payload['data']).to eq( + 'platform' => 'yeswehack', + 'program' => 'Test Program', + 'slug' => 'test-slug', + 'scopes_count' => 2, + 'scopes' => { 'web' => 2 } + ) + end + + it 'sends the configured custom headers' do + allow(ScopesExtractor::Config).to receive(:webhook_headers).and_return('Authorization' => 'Bearer secret') + + expect(ScopesExtractor::HTTP).to receive(:post).with( + url, + hash_including(headers: { 'Content-Type' => 'application/json', 'Authorization' => 'Bearer secret' }) + ) + + notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug') + end + end + + describe '#notify_removed_program' do + it 'posts a removed_program event' do + payload = sent_payload { notifier.notify_removed_program('hackerone', 'Test Program', 'test-slug') } + + expect(payload['event']).to eq('removed_program') + expect(payload['data']).to eq('platform' => 'hackerone', 'program' => 'Test Program', 'slug' => 'test-slug') + end + end + + describe '#notify_new_scope' do + it 'posts a new_scope event' do + payload = sent_payload { notifier.notify_new_scope('intigriti', 'Test Program', 'example.com', 'web') } + + expect(payload['event']).to eq('new_scope') + expect(payload['data']).to eq( + 'platform' => 'intigriti', 'program' => 'Test Program', 'value' => 'example.com', 'type' => 'web' + ) + end + + it 'skips scope types that are filtered out' do + allow(ScopesExtractor::Config).to receive(:webhook_new_scope_types).and_return(['web']) + + expect(ScopesExtractor::HTTP).not_to receive(:post) + notifier.notify_new_scope('intigriti', 'Test Program', 'com.example.app', 'mobile') + end + end + + describe '#notify_removed_scope' do + it 'posts a removed_scope event' do + payload = sent_payload { notifier.notify_removed_scope('bugcrowd', 'Test Program', 'example.com') } + + expect(payload['event']).to eq('removed_scope') + expect(payload['data']).to eq('platform' => 'bugcrowd', 'program' => 'Test Program', 'value' => 'example.com') + end + end + + describe '#notify_ignored_asset' do + it 'posts an ignored_asset event with its reason' do + payload = sent_payload { notifier.notify_ignored_asset('bugcrowd', 'Test Program', 'foo', 'Invalid format') } + + expect(payload['event']).to eq('ignored_asset') + expect(payload['data']['reason']).to eq('Invalid format') + end + end + + describe '#notify_error' do + it 'posts an error event' do + payload = sent_payload { notifier.notify_error('Sync Error', 'boom') } + + expect(payload['event']).to eq('error') + expect(payload['data']).to eq('title' => 'Sync Error', 'message' => 'boom') + end + end + + context 'when the webhook is disabled' do + before { allow(ScopesExtractor::Config).to receive(:webhook_enabled?).and_return(false) } + + it 'does not send anything' do + expect(ScopesExtractor::HTTP).not_to receive(:post) + notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug') + end + end + + context 'when no url is configured' do + before { allow(ScopesExtractor::Config).to receive(:webhook_url).and_return('') } + + it 'does not send anything' do + expect(ScopesExtractor::HTTP).not_to receive(:post) + notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug') + end + end + + context 'when the event is not in the configured list' do + before { allow(ScopesExtractor::Config).to receive(:webhook_events).and_return(['new_scope']) } + + it 'does not send anything' do + expect(ScopesExtractor::HTTP).not_to receive(:post) + notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug') + end + end + + context 'when the endpoint returns an error' do + let(:response) { double(code: 500, body: 'boom') } + + it 'logs the failure without raising' do + expect(ScopesExtractor.logger).to receive(:error).with(/Webhook notification failed: 500/) + expect { notifier.notify_error('Sync Error', 'boom') }.not_to raise_error + end + end + + context 'when the request raises' do + it 'logs the error without raising' do + allow(ScopesExtractor::HTTP).to receive(:post).and_raise(StandardError, 'network down') + + expect(ScopesExtractor.logger).to receive(:error).with(/Webhook notification error: network down/) + expect { notifier.notify_error('Sync Error', 'boom') }.not_to raise_error + end + end +end