diff --git a/README.md b/README.md index dacc8a9..f0d3c27 100644 --- a/README.md +++ b/README.md @@ -221,43 +221,113 @@ Example `SOCKET_JIRA_CONFIG_JSON` value | Environment Variable | Required | Default | Description | |:-------------------------|:---------|:--------|:-----------------------------------| -| SOCKET_SLACK_CONFIG_JSON | False | None | Slack webhook configuration (enables plugin when set). Alternatively, use --slack-webhook CLI flag. | +| SOCKET_SLACK_CONFIG_JSON | False | None | Slack configuration (enables plugin when set). Supports webhook or bot mode. Alternatively, use --slack-webhook CLI flag for simple webhook mode. | +| SOCKET_SLACK_BOT_TOKEN | False | None | Slack Bot User OAuth Token (starts with `xoxb-`). Required when using bot mode. | -Example `SOCKET_SLACK_CONFIG_JSON` value (simple webhook): +**Slack supports two modes:** + +1. **Webhook Mode** (default): Posts to incoming webhooks +2. **Bot Mode**: Posts via Slack API with bot token authentication + +###### Webhook Mode Examples + +Simple webhook: ````json -{"url": "https://REPLACE_ME_WEBHOOK"} +{"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"} ```` -Example with advanced filtering (reachability-only alerts): +Multiple webhooks with advanced filtering: ````json { + "mode": "webhook", "url": [ { "name": "prod_alerts", "url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + }, + { + "name": "critical_only", + "url": "https://hooks.slack.com/services/YOUR/OTHER/WEBHOOK/URL" } ], "url_configs": { "prod_alerts": { "reachability_alerts_only": true, - "always_send_reachability": true + "severities": ["high", "critical"] + }, + "critical_only": { + "severities": ["critical"] } } } ```` -**Advanced Configuration Options:** +###### Bot Mode Examples + +**Setting up a Slack Bot:** +1. Go to https://api.slack.com/apps and create a new app +2. Under "OAuth & Permissions", add the `chat:write` bot scope +3. Install the app to your workspace and copy the "Bot User OAuth Token" +4. Invite the bot to your channels: `/invite @YourBotName` + +Basic bot configuration: + +````json +{ + "mode": "bot", + "bot_configs": [ + { + "name": "security_alerts", + "channels": ["security-alerts", "dev-team"] + } + ] +} +```` + +Bot with filtering (reachability-only alerts): + +````json +{ + "mode": "bot", + "bot_configs": [ + { + "name": "critical_reachable", + "channels": ["security-critical"], + "severities": ["critical", "high"], + "reachability_alerts_only": true + }, + { + "name": "all_alerts", + "channels": ["security-all"], + "repos": ["myorg/backend", "myorg/frontend"] + } + ] +} +```` + +Set the bot token: +```bash +export SOCKET_SLACK_BOT_TOKEN="xoxb-your-bot-token-here" +``` -The `url_configs` object allows per-webhook filtering: +**Configuration Options:** +Webhook mode (`url_configs`): - `reachability_alerts_only` (boolean, default: false): When `--reach` is enabled, only send blocking alerts (error=true) from diff scans -- `always_send_reachability` (boolean, default: true): Send reachability alerts even on non-diff scans when `--reach` is enabled. Set to false to only send reachability alerts when there are diff alerts. - `repos` (array): Only send alerts for specific repositories (e.g., `["owner/repo1", "owner/repo2"]`) - `alert_types` (array): Only send specific alert types (e.g., `["malware", "typosquat"]`) - `severities` (array): Only send alerts with specific severities (e.g., `["high", "critical"]`) +Bot mode (`bot_configs` array items): +- `name` (string, required): Friendly name for this configuration +- `channels` (array, required): Channel names (without #) where alerts will be posted +- `severities` (array, optional): Only send alerts with specific severities (e.g., `["high", "critical"]`) +- `repos` (array, optional): Only send alerts for specific repositories +- `alert_types` (array, optional): Only send specific alert types +- `reachability_alerts_only` (boolean, default: false): Only send reachable vulnerabilities when using `--reach` + ## Automatic Git Detection The CLI now automatically detects repository information from your git environment, significantly simplifying usage in CI/CD pipelines: diff --git a/pyproject.toml b/pyproject.toml index d906dc6..47c55cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.60" +version = "2.2.62" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/session.md b/session.md new file mode 100644 index 0000000..5707e67 --- /dev/null +++ b/session.md @@ -0,0 +1,127 @@ +# Session Directions: Add Slack Bot Mode Support + +## Context +The Socket Python CLI currently supports Slack notifications via incoming webhooks. We need to add an alternative "bot" mode that uses a Slack App with Bot Token for more flexible channel routing. + +## Current Implementation +- File: `socketsecurity/plugins/slack.py` +- File: `socketsecurity/config.py` +- Env var: `SOCKET_SLACK_CONFIG_JSON` +- Current config uses `url` and `url_configs` for webhook routing + +## Requirements + +### 1. Add Mode Selection +- Add top-level `mode` field to Slack config +- Valid values: "webhook" (default), "bot" +- Mode determines which authentication and routing method to use + +### 2. Webhook Mode (existing, default) +```json +{ + "enabled": true, + "mode": "webhook", + "url": ["https://hooks.slack.com/..."], + "url_configs": { + "webhook_0": {"repos": ["repo1"], "severities": ["critical"]} + } +} +``` +Keep all existing webhook functionality unchanged. + +### 3. Bot Mode (new) +```json +{ + "enabled": true, + "mode": "bot", + "bot_configs": [ + { + "name": "critical_alerts", + "channels": ["security-alerts", "critical-incidents"], + "repos": ["prod-app"], + "severities": ["critical"], + "reachability_alerts_only": true + }, + { + "name": "all_alerts", + "channels": ["dev-alerts"], + "severities": ["high", "medium"] + } + ] +} +``` + +- New env var: `SOCKET_SLACK_BOT_TOKEN` (Bot User OAuth Token starting with `xoxb-`) +- Use `bot_configs` array instead of `url` + `url_configs` +- Each bot_config has: + - `name`: identifier for logging + - `channels`: array of Slack channel names or IDs to post to + - All existing filter options: `repos`, `severities`, `alert_types`, `reachability_alerts_only`, `always_send_reachability` + +### 4. Channel Routing +- Slack API accepts both channel names (without #) and channel IDs (C1234567890) +- Recommend supporting both: try name first, fallback to ID if needed +- API endpoint: `https://slack.com/api/chat.postMessage` +- Request format: +```python +{ + "channel": "channel-name", # or "C1234567890" + "blocks": blocks +} +``` +- Headers: `{"Authorization": f"Bearer {bot_token}", "Content-Type": "application/json"}` + +### 5. Implementation Tasks + +#### config.py +- No changes needed (config is loaded from JSON env var) + +#### slack.py +1. Update `send()` method: + - Check `self.config.get("mode", "webhook")` + - If "webhook": call existing `_send_webhook_alerts()` (refactor current logic) + - If "bot": call new `_send_bot_alerts()` + +2. Create `_send_bot_alerts()` method: + - Get bot token from env: `os.getenv("SOCKET_SLACK_BOT_TOKEN")` + - Validate token exists and starts with "xoxb-" + - Get `bot_configs` from config + - For each bot_config, filter alerts same way as webhooks + - For each channel in bot_config's channels array, post message via chat.postMessage API + +3. Create `_post_to_slack_api()` helper method: + - Takes bot_token, channel, blocks + - Posts to https://slack.com/api/chat.postMessage + - Returns response + - Log errors with channel name/ID for debugging + +4. Error handling: + - Log if bot token missing when mode is "bot" + - Handle API errors (invalid channel, missing permissions, rate limits) + - Parse Slack API response JSON (it returns 200 with error in body) + +5. Reuse existing: + - All filtering logic (`_filter_alerts`) + - All block building (`create_slack_blocks_from_diff`, `_create_reachability_slack_blocks_from_structured`) + - All reachability data loading + +### 6. Testing Considerations +- Test both modes don't interfere with each other +- Test channel name resolution +- Test channel ID usage +- Test multiple channels per bot_config +- Test error handling when bot token invalid or missing +- Verify block count limits still respected (50 blocks) + +### 7. Documentation Updates (README.md) +Add bot mode configuration examples and SOCKET_SLACK_BOT_TOKEN env var documentation. + +## Key Files to Modify +1. `socketsecurity/plugins/slack.py` - main implementation +2. `README.md` - add bot mode documentation + +## Notes +- Slack chat.postMessage returns HTTP 200 even on errors. Check response JSON for `"ok": false` +- Rate limit: 1 message per second per channel (more generous than webhooks) +- Channel names are case-insensitive, don't need # prefix +- Public and private channels both work if bot is invited diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index b4aba6c..13f0729 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.60' +__version__ = '2.2.62' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 9da7915..fa227b6 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -553,7 +553,10 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: # Finalize tier1 scan if reachability analysis was enabled if self.cli_config and self.cli_config.reach: - facts_file_path = self.cli_config.reach_output_file or ".socket.facts.json" + facts_file_path = os.path.join( + self.cli_config.target_path or ".", + self.cli_config.reach_output_file + ) log.debug(f"Reachability analysis enabled, finalizing tier1 scan for full scan {full_scan.id}") try: success = self.finalize_tier1_scan(full_scan.id, facts_file_path) diff --git a/socketsecurity/plugins/slack.py b/socketsecurity/plugins/slack.py index ba2cd6c..ab41cc5 100644 --- a/socketsecurity/plugins/slack.py +++ b/socketsecurity/plugins/slack.py @@ -1,4 +1,5 @@ import logging +import os import requests from socketsecurity.config import CliConfig from .base import Plugin @@ -20,10 +21,19 @@ def get_name(): return "slack" def send(self, diff, config: CliConfig): - if not self.config.get("enabled", False): - if config.enable_debug: - logger.debug("Slack plugin is disabled - skipping webhook notification") + # Check mode and route to appropriate handler + mode = self.config.get("mode", "webhook") + + if mode == "webhook": + self._send_webhook_alerts(diff, config) + elif mode == "bot": + self._send_bot_alerts(diff, config) + else: + logger.error(f"Invalid Slack mode '{mode}'. Valid modes are 'webhook' or 'bot'.") return + + def _send_webhook_alerts(self, diff, config: CliConfig): + """Send alerts using webhook mode.""" if not self.config.get("url"): logger.warning("Slack webhook URL not configured.") if config.enable_debug: @@ -37,7 +47,7 @@ def send(self, diff, config: CliConfig): logger.warning("No valid Slack webhook URLs configured.") return - logger.debug("Slack Plugin Enabled") + logger.debug("Slack Plugin Enabled (webhook mode)") logger.debug("Alert levels: %s", self.config.get("levels")) # Get url_configs parameter (filtering configuration) @@ -117,6 +127,214 @@ def send(self, diff, config: CliConfig): elif config.enable_debug: logger.debug(f"Slack webhook response for {name}: {response.status_code}") + def _send_bot_alerts(self, diff, config: CliConfig): + """Send alerts using bot mode with Slack API.""" + # Get bot token from environment + bot_token = os.getenv("SOCKET_SLACK_BOT_TOKEN") + + if not bot_token: + logger.error("SOCKET_SLACK_BOT_TOKEN environment variable not set for bot mode.") + return + + if not bot_token.startswith("xoxb-"): + logger.error("SOCKET_SLACK_BOT_TOKEN must start with 'xoxb-' (Bot User OAuth Token).") + return + + # Get bot_configs from configuration + bot_configs = self.config.get("bot_configs", []) + + if not bot_configs: + logger.warning("No bot_configs configured for bot mode.") + return + + logger.debug("Slack Plugin Enabled (bot mode)") + logger.debug("Alert levels: %s", self.config.get("levels")) + logger.debug(f"Number of bot_configs: {len(bot_configs)}") + logger.debug(f"config.reach: {config.reach}") + logger.debug(f"len(diff.new_alerts): {len(diff.new_alerts) if diff.new_alerts else 0}") + + # Get repo name from config + repo_name = config.repo or "" + + # Handle reachability data if --reach is enabled + if config.reach: + self._send_bot_reachability_alerts(bot_configs, bot_token, repo_name, config, diff) + + # Handle diff alerts (if any) + if not diff.new_alerts: + logger.debug("No new diff alerts to notify via Slack.") + else: + # Send to each configured bot_config with filtering + for bot_config in bot_configs: + name = bot_config.get("name", "unnamed") + channels = bot_config.get("channels", []) + + if not channels: + logger.warning(f"No channels configured for bot_config '{name}'. Skipping.") + continue + + # Filter alerts based on bot config + # When --reach is used, reachability_alerts_only applies to diff alerts + filtered_alerts = self._filter_alerts( + diff.new_alerts, + bot_config, + repo_name, + config, + is_reachability_data=False, + apply_reachability_only_filter=config.reach + ) + + if not filtered_alerts: + logger.debug(f"No diff alerts match filter criteria for bot_config '{name}'. Skipping.") + continue + + # Create a temporary diff object with filtered alerts for message creation + filtered_diff = Diff( + new_alerts=filtered_alerts, + diff_url=getattr(diff, "diff_url", ""), + new_packages=getattr(diff, "new_packages", []), + removed_packages=getattr(diff, "removed_packages", []), + packages=getattr(diff, "packages", {}) + ) + + message = self.create_slack_blocks_from_diff(filtered_diff, config) + + if config.enable_debug: + logger.debug(f"Bot config '{name}': Total diff alerts: {len(diff.new_alerts)}, Filtered alerts: {len(filtered_alerts)}") + logger.debug(f"Message blocks count: {len(message)}") + + # Send to each channel in the bot_config + for channel in channels: + logger.debug(f"Sending diff alerts message to channel '{channel}' (bot_config: {name})") + self._post_to_slack_api(bot_token, channel, message, config, name) + + def _send_bot_reachability_alerts(self, bot_configs: list, bot_token: str, repo_name: str, config: CliConfig, diff=None): + """Send reachability alerts using bot mode with Slack API.""" + # Construct path to socket facts file + facts_file_path = os.path.join(config.target_path or ".", f"{config.reach_output_file}") + logger.debug(f"Loading reachability data from {facts_file_path}") + + # Load socket facts file + facts_data = load_socket_facts(facts_file_path) + + if not facts_data: + logger.debug("No .socket.facts.json file found or failed to load") + return + + # Get components with vulnerabilities + components_with_vulns = get_components_with_vulnerabilities(facts_data) + + if not components_with_vulns: + logger.debug("No components with vulnerabilities found in .socket.facts.json") + return + + # Convert to alerts format + components_with_alerts = convert_to_alerts(components_with_vulns) + + if not components_with_alerts: + logger.debug("No alerts generated from .socket.facts.json") + return + + logger.debug(f"Found {len(components_with_alerts)} components with reachability alerts") + + # Send to each configured bot_config with filtering + for bot_config in bot_configs: + name = bot_config.get("name", "unnamed") + channels = bot_config.get("channels", []) + + if not channels: + logger.warning(f"No channels configured for bot_config '{name}'. Skipping.") + continue + + # Filter components based on severities only (for reachability data) + filtered_components = [] + for component in components_with_alerts: + component_alerts = component.get('alerts', []) + # Filter alerts using only severities + filtered_component_alerts = self._filter_alerts( + component_alerts, + bot_config, + repo_name, + config, + is_reachability_data=True + ) + + if filtered_component_alerts: + # Create a copy of component with only filtered alerts + filtered_component = component.copy() + filtered_component['alerts'] = filtered_component_alerts + filtered_components.append(filtered_component) + + if not filtered_components: + logger.debug(f"No reachability alerts match filter criteria for bot_config '{name}'. Skipping.") + continue + + # Format for Slack using the formatter (max 45 blocks for findings + 5 for header/footer) + slack_notifications = format_socket_facts_for_slack( + filtered_components, + max_blocks=45, + include_traces=True + ) + + # Convert to Slack blocks format and send + for notification in slack_notifications: + blocks = self._create_reachability_slack_blocks_from_structured( + notification, + config, + diff + ) + + if config.enable_debug: + logger.debug(f"Bot config '{name}': Reachability components: {len(filtered_components)}") + logger.debug(f"Message blocks count: {len(blocks)}") + + # Send to each channel in the bot_config + for channel in channels: + logger.debug(f"Sending reachability alerts message to channel '{channel}' (bot_config: {name})") + self._post_to_slack_api(bot_token, channel, blocks, config, name) + + def _post_to_slack_api(self, bot_token: str, channel: str, blocks: list, config: CliConfig, config_name: str = None): + """Post message to Slack using chat.postMessage API. + + Args: + bot_token: Slack bot token (starts with xoxb-) + channel: Channel name (without #) or channel ID (C1234567890) + blocks: List of Slack blocks to send + config: CliConfig object for debug logging + config_name: Name of the bot_config for logging + + Returns: + Response dict from Slack API + """ + url = "https://slack.com/api/chat.postMessage" + headers = { + "Authorization": f"Bearer {bot_token}", + "Content-Type": "application/json" + } + payload = { + "channel": channel, + "blocks": blocks + } + + try: + response = requests.post(url, headers=headers, json=payload) + response_data = response.json() + + # Slack returns 200 even on errors, check response JSON + if not response_data.get("ok", False): + error_msg = response_data.get("error", "unknown error") + logger.error(f"Slack API error for channel '{channel}': {error_msg}") + if config.enable_debug: + logger.debug(f"Full response: {response_data}") + elif config.enable_debug: + logger.debug(f"Successfully posted to channel '{channel}' (config: {config_name})") + + return response_data + + except Exception as e: + logger.error(f"Exception posting to Slack channel '{channel}': {str(e)}") + return {"ok": False, "error": str(e)} + def _filter_alerts( self, alerts: list, @@ -210,10 +428,13 @@ def _send_reachability_alerts(self, valid_webhooks: list, webhook_configs: dict, config: CliConfig object diff: Diff object containing diff_url for report link """ - logger.debug("Loading reachability data from .socket.facts.json") + # Construct path to socket facts file + import os as os_module + facts_file_path = os_module.path.join(config.target_path or ".", config.reach_output_file) + logger.debug(f"Loading reachability data from {facts_file_path}") # Load socket facts file - facts_data = load_socket_facts(".socket.facts.json") + facts_data = load_socket_facts(facts_file_path) if not facts_data: logger.debug("No .socket.facts.json file found or failed to load") diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 332d3e4..3045a17 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -591,6 +591,7 @@ def main_code(): ) log.info(f"Full scan created with ID: {diff.id}") log.info(f"Full scan report URL: {diff.report_url}") + output_handler.handle_output(diff) else: log.info("API Mode") diff = core.create_new_diff(