From 1a4bdf17e344aeb72849a6fceac4ab35a7c56831 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 27 Feb 2026 15:39:22 -0500 Subject: [PATCH] Add structured findings to webhook payload Webhook payloads now include a machine-readable findings array alongside the existing markdown content. Each connector (Trivy, Socket Tier1, OpenGrep, TruffleHog) builds structured finding objects during format. The WebhookNotifier aggregates these into the payload with a severity summary and ISO 8601 timestamp. The notification.content field remains for backward compatibility. TruffleHog findings intentionally omit redacted secret values from the structured output. --- .../core/connector/opengrep/webhook.py | 38 +++- .../core/connector/socket_tier1/webhook.py | 20 +- socket_basics/core/connector/trivy/webhook.py | 32 ++- .../core/connector/trufflehog/webhook.py | 18 +- .../core/notification/webhook_notifier.py | 38 +++- tests/test_webhook_notifier_params.py | 113 +++++++++- tests/test_webhook_payload.py | 206 ++++++++++++++++++ 7 files changed, 429 insertions(+), 36 deletions(-) create mode 100644 tests/test_webhook_payload.py diff --git a/socket_basics/core/connector/opengrep/webhook.py b/socket_basics/core/connector/opengrep/webhook.py index 8ba5d25..d66caea 100644 --- a/socket_basics/core/connector/opengrep/webhook.py +++ b/socket_basics/core/connector/opengrep/webhook.py @@ -35,30 +35,45 @@ def format_notifications(groups: Dict[str, List[Dict[str, Any]]]) -> List[Dict[s for subtype, items in groups.items(): if not items: # Skip empty groups continue - + rows = [] + findings: List[Dict[str, Any]] = [] for item in items: c = item['component'] a = item['alert'] props = a.get('props', {}) or {} full_path = props.get('filePath', a.get('location', {}).get('path')) or '-' - + try: file_name = Path(full_path).name except Exception: file_name = full_path - + + rule = props.get('ruleId', a.get('title', '')) + severity = a.get('severity', '') + lines = f"{props.get('startLine','')}-{props.get('endLine','')}" + rows.append([ - props.get('ruleId', a.get('title', '')), - a.get('severity', ''), + rule, + severity, file_name, full_path, - f"{props.get('startLine','')}-{props.get('endLine','')}", + lines, props.get('codeSnippet', '') or '', subtype, 'opengrep' ]) - + + findings.append({ + 'rule': rule, + 'severity': severity, + 'file': file_name, + 'path': full_path, + 'lines': lines, + 'language': subtype, + 'scanner': 'opengrep', + }) + # Create a separate dataset for each subtype/language group display_name = subtype_names.get(subtype, subtype.upper()) headers = ['Rule', 'Severity', 'File', 'Path', 'Lines', 'Code', 'SubType', 'Scanner'] @@ -67,13 +82,14 @@ def format_notifications(groups: Dict[str, List[Dict[str, Any]]]) -> List[Dict[s content_rows = [] for row in rows: content_rows.append(' | '.join(str(cell) for cell in row)) - + content = '\n'.join([header_row, separator_row] + content_rows) if rows else f"No {display_name} issues found." - + tables.append({ 'title': display_name, - 'content': content + 'content': content, + 'findings': findings, }) - + # Return list of tables - one per language group return tables \ No newline at end of file diff --git a/socket_basics/core/connector/socket_tier1/webhook.py b/socket_basics/core/connector/socket_tier1/webhook.py index df5ed20..92c5b85 100644 --- a/socket_basics/core/connector/socket_tier1/webhook.py +++ b/socket_basics/core/connector/socket_tier1/webhook.py @@ -24,18 +24,19 @@ def _make_purl(comp: Dict[str, Any]) -> str: def format_notifications(components_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Format for generic webhook - flexible structured format.""" + """Format for generic webhook - flexible structured format with findings.""" rows = [] + findings: List[Dict[str, Any]] = [] for comp in components_list: comp_name = str(comp.get('name') or comp.get('id') or '-') - + for a in comp.get('alerts', []): props = a.get('props', {}) or {} purl = str(props.get('purl') or _make_purl(comp) or comp_name) cve_id = str(props.get('ghsaId') or props.get('cveId') or a.get('title') or '') severity = str(a.get('severity') or props.get('severity') or '') reachability = str(props.get('reachability') or '') - + rows.append([ cve_id, severity, @@ -46,6 +47,16 @@ def format_notifications(components_list: List[Dict[str, Any]]) -> List[Dict[str str(props.get('ghsaId', '')), 'socket-tier1' ]) + + findings.append({ + 'package': comp_name, + 'version': str(comp.get('version', '')), + 'purl': purl, + 'cve': cve_id, + 'severity': severity, + 'reachability': reachability, + 'scanner': 'socket-tier1', + }) # Format as structured data for webhook if not rows: @@ -62,5 +73,6 @@ def format_notifications(components_list: List[Dict[str, Any]]) -> List[Dict[str return [{ 'title': 'Socket Tier1 Reachability Analysis', - 'content': content + 'content': content, + 'findings': findings, }] \ No newline at end of file diff --git a/socket_basics/core/connector/trivy/webhook.py b/socket_basics/core/connector/trivy/webhook.py index d71eb0e..2a691fb 100644 --- a/socket_basics/core/connector/trivy/webhook.py +++ b/socket_basics/core/connector/trivy/webhook.py @@ -18,7 +18,8 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc """ # Group vulnerabilities by package and severity package_groups = defaultdict(lambda: defaultdict(set)) # Use set to avoid duplicates - + findings: List[Dict[str, Any]] = [] + if scan_type == 'dockerfile': # Process dockerfile components for comp in mapping.values(): @@ -28,27 +29,45 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc severity = str(alert.get('severity', '')) message = str(alert.get('description', '')) resolution = str(props.get('resolution', '')) - + rule_info = f"{rule_id}|{message}|{resolution}" package_groups[rule_id][severity].add(rule_info) - + + findings.append({ + 'rule': rule_id, + 'severity': severity, + 'message': message, + 'resolution': resolution, + 'scanner': 'trivy', + }) + else: # image or vuln # Process package vulnerability components for comp in mapping.values(): comp_name = str(comp.get('name') or comp.get('id') or '-') comp_version = str(comp.get('version', '')) ecosystem = comp.get('qualifiers', {}).get('ecosystem', 'unknown') - + if comp_version: package_key = f"pkg:{ecosystem}/{comp_name}@{comp_version}" else: package_key = f"pkg:{ecosystem}/{comp_name}" - + for alert in comp.get('alerts', []): props = alert.get('props', {}) or {} cve_id = str(props.get('vulnerabilityId', '') or alert.get('title', '')) severity = str(alert.get('severity', '')) package_groups[package_key][severity].add(cve_id) + + findings.append({ + 'package': comp_name, + 'version': comp_version, + 'ecosystem': ecosystem, + 'purl': package_key, + 'cves': [cve_id], + 'severity': severity, + 'scanner': 'trivy', + }) # Create rows with proper formatting rows = [] @@ -111,5 +130,6 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc return [{ 'title': title, - 'content': content + 'content': content, + 'findings': findings, }] \ No newline at end of file diff --git a/socket_basics/core/connector/trufflehog/webhook.py b/socket_basics/core/connector/trufflehog/webhook.py index 9bb4ffc..ae41b0b 100644 --- a/socket_basics/core/connector/trufflehog/webhook.py +++ b/socket_basics/core/connector/trufflehog/webhook.py @@ -8,8 +8,9 @@ def format_notifications(mapping: Dict[str, Any]) -> List[Dict[str, Any]]: - """Format for generic webhook - flexible structured format.""" + """Format for generic webhook - flexible structured format with findings.""" rows = [] + findings: List[Dict[str, Any]] = [] for comp in mapping.values(): for a in comp.get('alerts', []): props = a.get('props', {}) or {} @@ -20,7 +21,7 @@ def format_notifications(mapping: Dict[str, Any]) -> List[Dict[str, Any]]: redacted = str(props.get('redactedValue', '')) verified = props.get('verified', False) secret_type = str(props.get('secretType', '')) - + rows.append([ detector, severity, @@ -31,6 +32,16 @@ def format_notifications(mapping: Dict[str, Any]) -> List[Dict[str, Any]]: str(verified), 'trufflehog' ]) + + # Omit redacted_value from structured findings to avoid leaking secrets + findings.append({ + 'detector': detector, + 'severity': severity, + 'file': file_path, + 'line': line, + 'verified': verified, + 'scanner': 'trufflehog', + }) # Format as structured data if not rows: @@ -47,5 +58,6 @@ def format_notifications(mapping: Dict[str, Any]) -> List[Dict[str, Any]]: return [{ 'title': 'TruffleHog Secret Detection Results', - 'content': content + 'content': content, + 'findings': findings, }] \ No newline at end of file diff --git a/socket_basics/core/notification/webhook_notifier.py b/socket_basics/core/notification/webhook_notifier.py index a5a5279..1f65e74 100644 --- a/socket_basics/core/notification/webhook_notifier.py +++ b/socket_basics/core/notification/webhook_notifier.py @@ -1,12 +1,25 @@ -from typing import Any, Dict +from datetime import datetime, timezone +from typing import Any, Dict, List import logging +import requests + from socket_basics.core.notification.base import BaseNotifier from socket_basics.core.config import get_webhook_url logger = logging.getLogger(__name__) +def _compute_summary(findings: List[Dict[str, Any]]) -> Dict[str, int]: + """Compute severity counts from a findings list.""" + counts: Dict[str, int] = {'total': len(findings), 'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for f in findings: + sev = str(f.get('severity', '')).lower() + if sev in counts: + counts[sev] += 1 + return counts + + class WebhookNotifier(BaseNotifier): """Webhook notifier: sends security findings to HTTP webhook endpoints. @@ -47,12 +60,10 @@ def notify(self, facts: Dict[str, Any]) -> None: # Send each notification as a separate webhook for item in valid_notifications: - title = item['title'] - content = item['content'] - self._send_webhook(facts, title, content) + self._send_webhook(facts, item) - def _send_webhook(self, facts: Dict[str, Any], title: str, content: str) -> None: - """Send a single webhook with title and content.""" + def _send_webhook(self, facts: Dict[str, Any], item: Dict[str, Any]) -> None: + """Send a single webhook with structured payload.""" if not self.url: logger.warning('WebhookNotifier: no webhook URL configured') return @@ -61,25 +72,30 @@ def _send_webhook(self, facts: Dict[str, Any], title: str, content: str) -> None repo = facts.get('repository', 'Unknown') branch = facts.get('branch', 'Unknown') - # Create webhook payload with pre-formatted content + title = item['title'] + content = item['content'] + findings = item.get('findings', []) + payload = { 'repository': repo, 'branch': branch, 'scanner': 'socket-security', - 'timestamp': facts.get('timestamp'), + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'scan_type': title, + 'summary': _compute_summary(findings), + 'findings': findings, 'notification': { 'title': title, - 'content': content + 'content': content, } } try: - import requests resp = requests.post(self.url, json=payload, timeout=10) if resp.status_code >= 400: logger.warning('WebhookNotifier: HTTP error %s: %s', resp.status_code, resp.text[:200]) else: logger.info('WebhookNotifier: sent webhook for "%s"', title) - + except Exception as e: logger.error('WebhookNotifier: exception sending webhook: %s', e) diff --git a/tests/test_webhook_notifier_params.py b/tests/test_webhook_notifier_params.py index 996f105..436f22b 100644 --- a/tests/test_webhook_notifier_params.py +++ b/tests/test_webhook_notifier_params.py @@ -1,6 +1,7 @@ import os +from unittest.mock import patch, MagicMock -from socket_basics.core.notification.webhook_notifier import WebhookNotifier +from socket_basics.core.notification.webhook_notifier import WebhookNotifier, _compute_summary from socket_basics.core.notification.manager import NotificationManager @@ -85,3 +86,113 @@ def test_webhook_app_config_precedence_over_env(monkeypatch): webhook = next(n for n in nm.notifiers if getattr(n, "name", "") == "webhook") assert webhook.url == "https://dashboard.example.com/hook" + + +# --- Structured payload tests --- + +def _make_notifier(): + return WebhookNotifier({"webhook_url": "https://example.com/hook"}) + + +def _make_facts(notifications): + return {'notifications': notifications} + + +def test_compute_summary_empty(): + assert _compute_summary([]) == {'total': 0, 'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + + +def test_compute_summary_counts(): + findings = [ + {'severity': 'critical'}, + {'severity': 'critical'}, + {'severity': 'high'}, + {'severity': 'medium'}, + {'severity': 'low'}, + {'severity': 'low'}, + ] + result = _compute_summary(findings) + assert result == {'total': 6, 'critical': 2, 'high': 1, 'medium': 1, 'low': 2} + + +def test_compute_summary_unknown_severity_in_total(): + findings = [{'severity': 'info'}, {'severity': 'critical'}] + result = _compute_summary(findings) + assert result['total'] == 2 + assert result['critical'] == 1 + + +@patch('socket_basics.core.notification.webhook_notifier.requests') +def test_payload_has_timestamp(mock_requests): + mock_requests.post.return_value = MagicMock(status_code=200) + n = _make_notifier() + findings = [{'severity': 'high', 'package': 'foo', 'scanner': 'trivy'}] + n.notify(_make_facts([{'title': 'Test', 'content': 'table', 'findings': findings}])) + + payload = mock_requests.post.call_args[1]['json'] + assert payload['timestamp'] is not None + assert 'T' in payload['timestamp'] + assert payload['timestamp'].endswith('Z') + + +@patch('socket_basics.core.notification.webhook_notifier.requests') +def test_payload_has_summary(mock_requests): + mock_requests.post.return_value = MagicMock(status_code=200) + n = _make_notifier() + findings = [ + {'severity': 'critical', 'package': 'a'}, + {'severity': 'high', 'package': 'b'}, + {'severity': 'high', 'package': 'c'}, + ] + n.notify(_make_facts([{'title': 'Test', 'content': 'md', 'findings': findings}])) + + payload = mock_requests.post.call_args[1]['json'] + assert payload['summary']['total'] == 3 + assert payload['summary']['critical'] == 1 + assert payload['summary']['high'] == 2 + + +@patch('socket_basics.core.notification.webhook_notifier.requests') +def test_payload_has_findings_array(mock_requests): + mock_requests.post.return_value = MagicMock(status_code=200) + n = _make_notifier() + findings = [{'severity': 'low', 'package': 'x', 'scanner': 'trivy'}] + n.notify(_make_facts([{'title': 'T', 'content': 'c', 'findings': findings}])) + + payload = mock_requests.post.call_args[1]['json'] + assert isinstance(payload['findings'], list) + assert len(payload['findings']) == 1 + assert payload['findings'][0]['package'] == 'x' + + +@patch('socket_basics.core.notification.webhook_notifier.requests') +def test_backward_compat_notification_field(mock_requests): + mock_requests.post.return_value = MagicMock(status_code=200) + n = _make_notifier() + n.notify(_make_facts([{'title': 'Title', 'content': 'markdown table'}])) + + payload = mock_requests.post.call_args[1]['json'] + assert payload['notification']['title'] == 'Title' + assert payload['notification']['content'] == 'markdown table' + + +@patch('socket_basics.core.notification.webhook_notifier.requests') +def test_missing_findings_defaults_empty(mock_requests): + """Connectors that haven't been updated yet still work (no findings key).""" + mock_requests.post.return_value = MagicMock(status_code=200) + n = _make_notifier() + n.notify(_make_facts([{'title': 'T', 'content': 'c'}])) + + payload = mock_requests.post.call_args[1]['json'] + assert payload['findings'] == [] + assert payload['summary']['total'] == 0 + + +@patch('socket_basics.core.notification.webhook_notifier.requests') +def test_scan_type_set_from_title(mock_requests): + mock_requests.post.return_value = MagicMock(status_code=200) + n = _make_notifier() + n.notify(_make_facts([{'title': 'Socket CVE Scanning Results: Dockerfile', 'content': 'c'}])) + + payload = mock_requests.post.call_args[1]['json'] + assert payload['scan_type'] == 'Socket CVE Scanning Results: Dockerfile' diff --git a/tests/test_webhook_payload.py b/tests/test_webhook_payload.py new file mode 100644 index 0000000..9282dba --- /dev/null +++ b/tests/test_webhook_payload.py @@ -0,0 +1,206 @@ +"""Tests for each connector's webhook formatter returning structured findings.""" + +from socket_basics.core.connector.trivy.webhook import format_notifications as trivy_format +from socket_basics.core.connector.socket_tier1.webhook import format_notifications as tier1_format +from socket_basics.core.connector.opengrep.webhook import format_notifications as opengrep_format +from socket_basics.core.connector.trufflehog.webhook import format_notifications as trufflehog_format + + +# --- Trivy --- + +class TestTrivyWebhookFindings: + def _make_vuln_mapping(self): + return { + 'comp1': { + 'name': 'bson', + 'version': '1.0.9', + 'qualifiers': {'ecosystem': 'npm'}, + 'alerts': [{ + 'severity': 'critical', + 'props': {'vulnerabilityId': 'CVE-2020-7610'}, + }], + } + } + + def _make_dockerfile_mapping(self): + return { + 'comp1': { + 'alerts': [{ + 'severity': 'high', + 'title': 'DS001', + 'description': 'Use COPY instead of ADD', + 'props': {'ruleId': 'DS001', 'resolution': 'Replace ADD with COPY'}, + }], + } + } + + def test_vuln_findings_key_present(self): + result = trivy_format(self._make_vuln_mapping(), 'test', 'vuln') + assert 'findings' in result[0] + + def test_vuln_findings_structure(self): + result = trivy_format(self._make_vuln_mapping(), 'test', 'vuln') + f = result[0]['findings'][0] + assert f['package'] == 'bson' + assert f['version'] == '1.0.9' + assert f['ecosystem'] == 'npm' + assert f['purl'] == 'pkg:npm/bson@1.0.9' + assert f['cves'] == ['CVE-2020-7610'] + assert f['severity'] == 'critical' + assert f['scanner'] == 'trivy' + + def test_vuln_no_pkg_unknown(self): + result = trivy_format(self._make_vuln_mapping(), 'test', 'vuln') + f = result[0]['findings'][0] + assert 'pkg:unknown/' not in f['purl'] + + def test_dockerfile_findings_structure(self): + result = trivy_format(self._make_dockerfile_mapping(), 'test', 'dockerfile') + f = result[0]['findings'][0] + assert f['rule'] == 'DS001' + assert f['severity'] == 'high' + assert f['message'] == 'Use COPY instead of ADD' + assert f['resolution'] == 'Replace ADD with COPY' + assert f['scanner'] == 'trivy' + + def test_content_still_present(self): + result = trivy_format(self._make_vuln_mapping(), 'test', 'vuln') + assert 'content' in result[0] + assert 'title' in result[0] + + def test_empty_mapping_returns_empty_findings(self): + result = trivy_format({}, 'test', 'vuln') + assert result[0]['findings'] == [] + + +# --- Socket Tier1 --- + +class TestTier1WebhookFindings: + def _make_components(self): + return [{ + 'name': 'lodash', + 'type': 'npm', + 'version': '4.17.20', + 'alerts': [{ + 'severity': 'high', + 'props': { + 'ghsaId': 'GHSA-xxxx-yyyy', + 'cveId': 'CVE-2021-23337', + 'reachability': 'reachable', + 'purl': 'pkg:npm/lodash@4.17.20', + }, + }], + }] + + def test_findings_key_present(self): + result = tier1_format(self._make_components()) + assert 'findings' in result[0] + + def test_findings_structure(self): + result = tier1_format(self._make_components()) + f = result[0]['findings'][0] + assert f['package'] == 'lodash' + assert f['version'] == '4.17.20' + assert f['purl'] == 'pkg:npm/lodash@4.17.20' + assert f['severity'] == 'high' + assert f['reachability'] == 'reachable' + assert f['scanner'] == 'socket-tier1' + + def test_empty_returns_empty_findings(self): + result = tier1_format([]) + assert result[0]['findings'] == [] + + +# --- OpenGrep --- + +class TestOpenGrepWebhookFindings: + def _make_groups(self): + return { + 'sast-python': [{ + 'component': {'name': 'app.py'}, + 'alert': { + 'severity': 'medium', + 'title': 'python.flask.security.injection', + 'props': { + 'ruleId': 'python.flask.security.injection', + 'filePath': '/src/app.py', + 'startLine': '10', + 'endLine': '12', + }, + }, + }] + } + + def test_findings_key_present(self): + result = opengrep_format(self._make_groups()) + assert 'findings' in result[0] + + def test_findings_structure(self): + result = opengrep_format(self._make_groups()) + f = result[0]['findings'][0] + assert f['rule'] == 'python.flask.security.injection' + assert f['severity'] == 'medium' + assert f['file'] == 'app.py' + assert f['path'] == '/src/app.py' + assert f['lines'] == '10-12' + assert f['language'] == 'sast-python' + assert f['scanner'] == 'opengrep' + + def test_empty_groups_returns_empty(self): + result = opengrep_format({}) + assert result == [] + + def test_empty_items_skipped(self): + result = opengrep_format({'sast-python': []}) + assert result == [] + + +# --- TruffleHog --- + +class TestTruffleHogWebhookFindings: + def _make_mapping(self): + return { + 'comp1': { + 'alerts': [{ + 'severity': 'high', + 'title': 'AWS', + 'props': { + 'detectorName': 'AWS', + 'filePath': '.env', + 'lineNumber': '5', + 'redactedValue': 'AKIA****XXXX', + 'verified': True, + 'secretType': 'aws_access_key', + }, + }], + } + } + + def test_findings_key_present(self): + result = trufflehog_format(self._make_mapping()) + assert 'findings' in result[0] + + def test_findings_structure(self): + result = trufflehog_format(self._make_mapping()) + f = result[0]['findings'][0] + assert f['detector'] == 'AWS' + assert f['severity'] == 'high' + assert f['file'] == '.env' + assert f['line'] == '5' + assert f['verified'] is True + assert f['scanner'] == 'trufflehog' + + def test_findings_omit_redacted_value(self): + result = trufflehog_format(self._make_mapping()) + f = result[0]['findings'][0] + assert 'redacted_value' not in f + assert 'redactedValue' not in f + + def test_markdown_still_has_redacted(self): + """The markdown content (for human reading) should still include redacted values.""" + result = trufflehog_format(self._make_mapping()) + assert 'AKIA****XXXX' in result[0]['content'] + + def test_empty_mapping_returns_empty_findings(self): + result = trufflehog_format({}) + assert result[0]['findings'] == []