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
124 changes: 104 additions & 20 deletions backend/securescan/api/scans.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
delete_scan_cascade,
get_findings,
get_findings_with_state,
get_notification_threshold,
get_scan,
get_scan_summary,
get_scans,
Expand All @@ -42,6 +43,7 @@
ScanRequest,
ScanStatus,
ScanSummary,
Severity,
)
from ..reports import ReportGenerator
from ..scanners import get_scanners_for_types
Expand Down Expand Up @@ -145,23 +147,76 @@ def _log_scan_event(event: str, *, scan_id: str, **fields: Any) -> None:
_NOTIF_SCANNER_ERROR_TRUNCATE = 100


# Numeric ordering for the finding-severity ladder used by per-event
# notification thresholds (issue #6). The dispatcher emits a
# notification only when the event's effective severity is >= the
# configured threshold.
_SEVERITY_RANK: dict[Severity, int] = {
Severity.INFO: 0,
Severity.LOW: 1,
Severity.MEDIUM: 2,
Severity.HIGH: 3,
Severity.CRITICAL: 4,
}


def _severity_rank(value: Severity | str | None) -> int:
"""Map a Severity enum / raw string to its rank, defaulting to info.

Defensive against unexpected string values (e.g. a future severity
label) so a typo cannot accidentally suppress notifications: an
unknown level is treated as `info` (lowest rank).
"""
if value is None:
return _SEVERITY_RANK[Severity.INFO]
if isinstance(value, Severity):
return _SEVERITY_RANK[value]
try:
return _SEVERITY_RANK[Severity(value)]
except (ValueError, KeyError):
return _SEVERITY_RANK[Severity.INFO]


def _max_severity_from_summary(fields: dict[str, Any]) -> Severity | None:
"""Return the highest finding severity present in a scan.complete event.

Looks at an explicit ``max_severity`` field first (preferred --
cheap to compute at publish time). Falls back to scanning the
standard summary count fields (``critical``/``high``/...). Returns
``None`` when the event has no findings -- the caller treats that
as "do not notify" regardless of threshold, preserving the
pre-issue-6 "no bell on a clean scan" behavior.
"""
explicit = fields.get("max_severity")
if explicit:
try:
return Severity(explicit) if not isinstance(explicit, Severity) else explicit
except ValueError:
pass
for sev in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO):
count = fields.get(sev.value)
if isinstance(count, int) and count > 0:
return sev
return None


async def _create_notification_for_event(event: str, scan_id: str, fields: dict[str, Any]) -> None:
"""Persist a notification for the small subset of events that warrant one.

Filtering rules (deliberately conservative -- the bell should
surface signal, not be a duplicate of the SSE feed):

* ``scan.complete`` — only when ``findings_count > 0``. Severity
is ``warning`` whenever findings were found, ``info`` otherwise.
Per spec we use the simple count-based rule rather than fetching
the scan summary to count critical/high (extra DB call per
event). When findings_count == 0 we don't notify at all, so
the ``info`` branch is structurally unreachable today; it's
kept for symmetry in case the filter is loosened later.
* ``scan.failed`` — always notify; severity ``error``.
* ``scanner.failed`` — always notify; severity ``warning``.
Per-event thresholds (issue #6, see
`database.NOTIFICATION_THRESHOLD_DEFAULTS` and the
`notification_settings` table) gate dispatch:

* ``scan.complete`` - effective severity is the highest finding
severity in the scan summary (or `None` for a clean scan, which
always skips). Default threshold: ``medium``.
* ``scan.failed`` - effective severity is ``critical`` (always
passes any threshold up to and including ``critical``). Default
threshold: ``info`` (always notify).
* ``scanner.failed`` - same as ``scan.failed``. Default threshold:
``info``.
* Everything else (scan.start, scanner.start, scanner.complete,
scanner.skipped, scan.cancelled) no notification. Those
scanner.skipped, scan.cancelled) -- no notification. Those
events are loud on the SSE stream while the dashboard is open;
persisting them all would drown the bell.

Expand All @@ -174,15 +229,20 @@ async def _create_notification_for_event(event: str, scan_id: str, fields: dict[
if event == "scan.complete":
findings_count = int(fields.get("findings_count", 0) or 0)
if findings_count <= 0:
# A clean scan never buzzes the bell, regardless of
# threshold -- there is no "event severity" to test.
return
# Treat a missing max_severity as "critical" so legacy
# callers (e.g. older publish sites or unit tests that
# only pass `findings_count`) continue to fire under the
# default `medium` threshold. The hot path always
# provides max_severity.
event_sev = _max_severity_from_summary(fields) or Severity.CRITICAL
threshold = await get_notification_threshold("scan.complete")
if _severity_rank(event_sev) < _severity_rank(threshold):
return
target_path = fields.get("target") or fields.get("target_path") or ""
severity = (
NotificationSeverity.WARNING if findings_count > 0 else NotificationSeverity.INFO
)
# Build the body defensively: if the publishing site forgot
# to include a target field, fall back to "<N> findings"
# rather than producing the dangling "<N> findings on "
# string the dashboard ended up rendering pre-v0.11.5.
severity = NotificationSeverity.WARNING
body = (
f"{findings_count} findings on {target_path}"
if target_path
Expand All @@ -198,6 +258,11 @@ async def _create_notification_for_event(event: str, scan_id: str, fields: dict[
return

if event == "scan.failed":
threshold = await get_notification_threshold("scan.failed")
# Failure events are synthesized at "critical" so any
# threshold up to the top of the ladder still fires.
if _severity_rank(Severity.CRITICAL) < _severity_rank(threshold):
return
error = str(fields.get("error", "") or "")[:_NOTIF_BODY_TRUNCATE]
await insert_notification(
type="scan.failed",
Expand All @@ -209,6 +274,9 @@ async def _create_notification_for_event(event: str, scan_id: str, fields: dict[
return

if event == "scanner.failed":
threshold = await get_notification_threshold("scanner.failed")
if _severity_rank(Severity.CRITICAL) < _severity_rank(threshold):
return
scanner = fields.get("scanner", "scanner")
error = str(fields.get("error", "") or "")[:_NOTIF_SCANNER_ERROR_TRUNCATE]
await insert_notification(
Expand Down Expand Up @@ -439,6 +507,22 @@ async def _run_one(scanner):
duration_s=round(time.perf_counter() - scan_started_perf, 2),
scanner_count=len(scanners_run),
findings_count=summary.total_findings,
# Highest severity present in the run, used by the
# notification dispatcher to gate on the per-event
# threshold (issue #6). `None` for a clean scan.
max_severity=(
Severity.CRITICAL.value
if summary.critical
else Severity.HIGH.value
if summary.high
else Severity.MEDIUM.value
if summary.medium
else Severity.LOW.value
if summary.low
else Severity.INFO.value
if summary.info
else None
),
)
except asyncio.CancelledError:
latest_scan = await get_scan(scan_id)
Expand Down
97 changes: 97 additions & 0 deletions backend/securescan/api/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Per-event notification threshold settings (issue #6).

Endpoints
---------
* ``GET /api/settings/notifications`` - admin: list thresholds.
Always returns a row per known event type with the default applied
server-side when the underlying table has no row yet.
* ``PATCH /api/settings/notifications`` - admin: update one or more
thresholds. Partial updates are allowed; unspecified events keep
their current (or default) value.

Both endpoints require the ``admin`` scope -- the threshold gates
operator-facing notifications and an attacker who could lower it to
"critical" would silence routine failures.
"""

from __future__ import annotations

from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field

from ..auth import require_scope
from ..database import (
get_notification_settings,
upsert_notification_threshold,
)
from ..models import (
NotificationEventType,
NotificationSettings,
NotificationThresholdSetting,
Severity,
)

router = APIRouter(prefix="/api/settings", tags=["settings"])


class NotificationThresholdUpdate(BaseModel):
"""Single threshold update entry inside `NotificationSettingsPatch`.

Pydantic validation rejects unknown event types and severities at
the boundary, so the handler can persist without re-checking.
"""

event_type: NotificationEventType
min_severity: Severity


class NotificationSettingsPatch(BaseModel):
"""PATCH body: a list of (event_type, min_severity) updates.

Empty list is allowed (no-op) so the UI can submit a clean form
without special-casing the "nothing changed" path.
"""

thresholds: list[NotificationThresholdUpdate] = Field(default_factory=list)


@router.get(
"/notifications",
response_model=NotificationSettings,
dependencies=[Depends(require_scope("admin"))],
)
async def get_notification_settings_endpoint() -> NotificationSettings:
"""Return the current threshold for every known event type.

Defaults are applied server-side (see
`database.NOTIFICATION_THRESHOLD_DEFAULTS`). The response is
deterministic in event ordering so the UI can render without
extra sorting.
"""
rows = await get_notification_settings()
return NotificationSettings(thresholds=rows)


@router.patch(
"/notifications",
response_model=NotificationSettings,
dependencies=[Depends(require_scope("admin"))],
)
async def patch_notification_settings_endpoint(
body: NotificationSettingsPatch,
) -> NotificationSettings:
"""Upsert one or more thresholds; return the full post-update state.

Duplicate event_type entries in a single request are tolerated;
the last value wins (insertion order). Unspecified events are
untouched.
"""
seen: dict[str, NotificationThresholdSetting] = {}
for entry in body.thresholds:
await upsert_notification_threshold(entry.event_type.value, entry.min_severity)
seen[entry.event_type.value] = NotificationThresholdSetting(
event_type=entry.event_type,
min_severity=entry.min_severity,
)
rows = await get_notification_settings()
return NotificationSettings(thresholds=rows)
105 changes: 105 additions & 0 deletions backend/securescan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
FindingState,
FindingWithState,
Notification,
NotificationEventType,
NotificationSeverity,
NotificationThresholdSetting,
SBOMComponent,
SBOMDocument,
Scan,
Expand Down Expand Up @@ -997,6 +999,109 @@ async def prune_old_notifications(older_than_days: int = 30) -> int:
await db.close()


# --- Notification settings (issue #6) ------------------------------------
#
# Per-event-type minimum severity thresholds. The table is populated
# lazily: a missing row means "use the application default" so existing
# deployments upgrade without behavior change. Defaults are kept in code
# (here) rather than seeded via a migration so they are easy to tune in
# a future release without writing a data migration.

# Default minimum severity per event type. `medium` for scan.complete
# is an intentional, documented approximation of the pre-issue-6 rule
# ("any non-zero findings_count fires"); see migration 007 for the
# rationale. `info` is the lowest threshold and effectively means
# "always notify" -- fail events are synthesized with severity
# `critical` so any threshold up to and including `critical` will pass.
NOTIFICATION_THRESHOLD_DEFAULTS: dict[str, Severity] = {
NotificationEventType.SCAN_COMPLETE.value: Severity.MEDIUM,
NotificationEventType.SCAN_FAILED.value: Severity.INFO,
NotificationEventType.SCANNER_FAILED.value: Severity.INFO,
}


async def get_notification_settings() -> list[NotificationThresholdSetting]:
"""Return one threshold per known event type, with defaults filled in.

Always returns a row for every value of `NotificationEventType`,
even when the table is empty. Order matches the enum declaration
so the UI renders deterministically.
"""
db = await _get_db()
try:
async with db.execute(
"SELECT event_type, min_severity, updated_at FROM notification_settings"
) as cursor:
rows = {r["event_type"]: r for r in await cursor.fetchall()}
finally:
await db.close()
out: list[NotificationThresholdSetting] = []
for event in NotificationEventType:
row = rows.get(event.value)
if row is None:
out.append(
NotificationThresholdSetting(
event_type=event,
min_severity=NOTIFICATION_THRESHOLD_DEFAULTS[event.value],
updated_at=None,
)
)
else:
out.append(
NotificationThresholdSetting(
event_type=event,
min_severity=Severity(row["min_severity"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
)
return out


async def get_notification_threshold(event_type: str) -> Severity:
"""Return the configured min severity for ``event_type``, or its default.

Convenience wrapper used by the dispatcher hot-path. A separate
query (rather than reusing `get_notification_settings`) keeps the
per-event work to a single SELECT on a tiny table.
"""
default = NOTIFICATION_THRESHOLD_DEFAULTS.get(event_type, Severity.INFO)
db = await _get_db()
try:
async with db.execute(
"SELECT min_severity FROM notification_settings WHERE event_type = ?",
(event_type,),
) as cursor:
row = await cursor.fetchone()
finally:
await db.close()
if row is None:
return default
try:
return Severity(row["min_severity"])
except ValueError:
# Defensive: a manually-edited DB could hold a stale value.
# Fall back to the default rather than throwing in the
# notification dispatch path.
return default


async def upsert_notification_threshold(event_type: str, min_severity: Severity) -> None:
"""Insert or replace one row of `notification_settings`."""
now = datetime.utcnow().isoformat()
db = await _get_db()
try:
await db.execute(
"INSERT INTO notification_settings (event_type, min_severity, updated_at) "
"VALUES (?, ?, ?) "
"ON CONFLICT(event_type) DO UPDATE SET "
"min_severity = excluded.min_severity, updated_at = excluded.updated_at",
(event_type, min_severity.value, now),
)
await db.commit()
finally:
await db.close()


# --- Outbound webhooks (BE-WEBHOOKS) -------------------------------------
#
# Two tables, one queue:
Expand Down
Loading
Loading