diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index 80f2000a..5e7b7bac 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -172,6 +172,9 @@ class Settings(BaseSettings): smtp_from_email: str = "noreply@secuscan.io" smtp_use_tls: bool = True + # Slack Webhook Configuration + slack_webhook_url: Optional[str] = None + class Config: env_prefix = "SECUSCAN_" case_sensitive = False diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 90c43a98..3cb44e9d 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -1844,6 +1844,10 @@ async def _dispatch_task_notifications(self, db, task_id: str) -> None: ) if sent: logger.info("Task %s: delivered %d notification(s)", task_id, sent) + + # Send Slack Webhook notification for scan completion + from .notification_service import process_slack_notification + await process_slack_notification(db, task_id) except Exception as exc: logger.warning( "Task %s: notification dispatch failed: %s", diff --git a/backend/secuscan/notification_service.py b/backend/secuscan/notification_service.py index ad65c018..03365bda 100644 --- a/backend/secuscan/notification_service.py +++ b/backend/secuscan/notification_service.py @@ -652,3 +652,126 @@ async def process_task_notifications( for row in findings: results.extend(await process_finding_notifications(db, str(row["id"]))) return results + + +async def process_slack_notification(db: Database, task_id: str) -> None: + """Send a structured JSON payload and a clean block message to the configured Slack Webhook after scan completion.""" + from .config import settings + + webhook_url = settings.slack_webhook_url + if not webhook_url: + return + + # Fetch task details + task = await db.fetchone("SELECT * FROM tasks WHERE id = ?", (task_id,)) + if not task: + logger.warning("Slack notification: Task %s not found in database", task_id) + return + + status = str(task.get("status") or "unknown").upper() + tool_name = task.get("tool_name") or task.get("plugin_id") or "Security Scan" + target = task.get("target") or "Unknown Target" + duration = task.get("duration_seconds") + duration_str = f"{duration:.2f}s" if duration is not None else "N/A" + + # Fetch findings to count and build severity breakdown + findings = await db.fetchall( + "SELECT severity FROM findings WHERE task_id = ?", + (task_id,), + ) + total_findings = len(findings) + + severity_counts: Dict[str, int] = {} + for row in findings: + sev = str(row.get("severity") or "info").lower() + severity_counts[sev] = severity_counts.get(sev, 0) + 1 + + # Formulate severity breakdown message + severity_lines = [] + for sev in ["critical", "high", "medium", "low", "info"]: + count = severity_counts.get(sev, 0) + if count > 0 or sev in ["critical", "high", "medium"]: + severity_lines.append(f"• *{sev.capitalize()}:* {count}") + severity_text = "\n".join(severity_lines) + + # Status-specific formatting + status_icon = "✅" if status == "COMPLETED" else "❌" if status == "FAILED" else "ℹ️" + + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"{status_icon} SecuScan: Scan {status.capitalize()}", + "emoji": True + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": f"*Tool:*\n{tool_name}" + }, + { + "type": "mrkdwn", + "text": f"*Target:*\n{target}" + }, + { + "type": "mrkdwn", + "text": f"*Status:*\n{status}" + }, + { + "type": "mrkdwn", + "text": f"*Duration:*\n{duration_str}" + } + ] + } + ] + + if status == "FAILED" and task.get("error_message"): + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Error Message:*\n{task.get('error_message')}" + } + }) + else: + blocks.append({ + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": f"*Total Findings:*\n{total_findings}" + }, + { + "type": "mrkdwn", + "text": f"*Severity Breakdown:*\n{severity_text}" + } + ] + }) + + payload = { + "text": f"SecuScan scan of {target} finished with status: {status}", + "blocks": blocks, + "scan_data": { + "task_id": task_id, + "tool_name": tool_name, + "target": target, + "status": status.lower(), + "duration_seconds": duration, + "total_findings": total_findings, + "severity_counts": severity_counts, + "error_message": task.get("error_message") + } + } + + try: + ok, error = await send_webhook(webhook_url, payload) + if ok: + logger.info("Slack notification for task %s sent successfully", task_id) + else: + logger.warning("Failed to send Slack notification for task %s: %s", task_id, error) + except Exception as exc: + logger.error("Error sending Slack notification for task %s: %s", task_id, exc, exc_info=True) diff --git a/testing/backend/unit/test_notification_service.py b/testing/backend/unit/test_notification_service.py index 826d0bd6..a247091b 100644 --- a/testing/backend/unit/test_notification_service.py +++ b/testing/backend/unit/test_notification_service.py @@ -683,3 +683,57 @@ async def tracking_connect_tcp( assert tls_hostname == "hooks.example.com", ( f"TLS SNI must use original hostname (hooks.example.com), got {tls_hostname!r}" ) + + +@pytest.mark.asyncio +async def test_process_slack_notification_success(test_db, monkeypatch): + """process_slack_notification compiles task info, counts findings, and sends webhook successfully.""" + task_id, finding_id = await _seed_finding(test_db, severity="high") + + monkeypatch.setattr(settings, "slack_webhook_url", "https://slack.example.invalid/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX") + + mock_send = AsyncMock(return_value=(True, None)) + monkeypatch.setattr("backend.secuscan.notification_service.send_webhook", mock_send) + + from backend.secuscan.notification_service import process_slack_notification + await process_slack_notification(test_db, task_id) + + assert mock_send.call_count == 1 + call_args, _ = mock_send.call_args + target_url = call_args[0] + payload = call_args[1] + + assert target_url == "https://slack.example.invalid/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + assert "blocks" in payload + assert len(payload["blocks"]) >= 3 + assert "High" in payload["blocks"][2]["fields"][1]["text"] + assert "Total Findings" in payload["blocks"][2]["fields"][0]["text"] + + +@pytest.mark.asyncio +async def test_process_slack_notification_failed_task(test_db, monkeypatch): + """process_slack_notification sends error details when status is FAILED.""" + task_id = str(uuid.uuid4()) + await test_db.execute( + """ + INSERT INTO tasks ( + id, plugin_id, tool_name, target, status, inputs_json, consent_granted, error_message + ) VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'failed', '{}', 1, 'Connection refused') + """, + (task_id,), + ) + + monkeypatch.setattr(settings, "slack_webhook_url", "https://slack.example.invalid/services/test") + mock_send = AsyncMock(return_value=(True, None)) + monkeypatch.setattr("backend.secuscan.notification_service.send_webhook", mock_send) + + from backend.secuscan.notification_service import process_slack_notification + await process_slack_notification(test_db, task_id) + + assert mock_send.call_count == 1 + call_args, _ = mock_send.call_args + payload = call_args[1] + + assert "blocks" in payload + assert "Error Message" in payload["blocks"][2]["text"]["text"] + assert "Connection refused" in payload["blocks"][2]["text"]["text"]