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
3 changes: 3 additions & 0 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/secuscan/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions backend/secuscan/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
54 changes: 54 additions & 0 deletions testing/backend/unit/test_notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading