Skip to content
Open
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
119 changes: 115 additions & 4 deletions backend/secuscan/risk_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@
"low": 2.5,
}

# System exposure context factors (multiplicative)
EXPOSURE_CONTEXT_MAP: Dict[str, float] = {
"public": 1.5, # Public-facing systems: higher multiplier
"internet_facing": 1.3, # Internet-accessible but not primary public interface
"internal": 0.8, # Internal only: lower multiplier
"private": 0.6, # Development/private systems: minimal context
}

# Business criticality factors (multiplicative)
CRITICALITY_MAP: Dict[str, float] = {
"critical": 1.5, # Critical business function
"high": 1.25, # Important business function
"medium": 1.0, # Standard business function (no multiplier)
"low": 0.8, # Non-critical function
}

# Weights used in the composite score (must sum to 1.0)
WEIGHTS = {
"severity": 0.30,
Expand Down Expand Up @@ -81,6 +97,59 @@ def _clamp(value: float, lo: float = 0.0, hi: float = 10.0) -> float:
return max(lo, min(hi, value))


def _system_exposure_factor(exposure_context: Optional[str]) -> float:
"""Get the exposure context multiplier for severity adjustment."""
if exposure_context is None:
return 1.0
return EXPOSURE_CONTEXT_MAP.get(exposure_context.lower(), 1.0)


def _business_criticality_factor(criticality: Optional[str]) -> float:
"""Get the business criticality multiplier for severity adjustment."""
if criticality is None:
return 1.0
return CRITICALITY_MAP.get(criticality.lower(), 1.0)


def _contextual_severity_score(
base_severity: float,
exposure_context: Optional[str] = None,
business_criticality: Optional[str] = None,
custom_override: Optional[float] = None,
) -> float:
"""
Calculate context-aware severity score.

Accounts for system exposure (public/private/internal) and business
criticality (data sensitivity, user count) to provide more accurate
prioritization beyond raw CVSS score.

Parameters
----------
base_severity : float
Base severity score 0-10 (from CVSS)
exposure_context : str or None
System exposure: 'public', 'internet_facing', 'internal', 'private'
business_criticality : str or None
Business impact: 'critical', 'high', 'medium', 'low'
custom_override : float or None
Manual override (0-10). If set, bypasses calculated score.

Returns
-------
float
Context-adjusted severity score (0-10)
"""
if custom_override is not None:
return _clamp(custom_override)

exposure_mult = _system_exposure_factor(exposure_context)
criticality_mult = _business_criticality_factor(business_criticality)

contextual_score = base_severity * exposure_mult * criticality_mult
return _clamp(contextual_score)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
Expand All @@ -92,6 +161,9 @@ def compute_risk_score(
asset_exposure: Optional[str] = None,
discovered_at: Optional[datetime] = None,
confidence: Optional[float] = None,
exposure_context: Optional[str] = None,
business_criticality: Optional[str] = None,
severity_override: Optional[float] = None,
) -> float:
"""
Compute a weighted composite risk score in [0, 10].
Expand All @@ -108,8 +180,22 @@ def compute_risk_score(
When the finding was discovered. Defaults to 90-day-old equivalent.
confidence : float or None
0–1. Defaults to 0.5 when None.
exposure_context : str or None
System exposure: 'public', 'internet_facing', 'internal', 'private'.
Adjusts severity by system context.
business_criticality : str or None
Business impact: 'critical', 'high', 'medium', 'low'.
Adjusts severity by business function importance.
severity_override : float or None
Manual override for severity (0-10). Bypasses context calculation.
"""
sv = _severity_score(severity)
base_severity = _severity_score(severity)
sv = _contextual_severity_score(
base_severity,
exposure_context=exposure_context,
business_criticality=business_criticality,
custom_override=severity_override,
)
ev = _clamp(exploitability if exploitability is not None else 5.0)
av = ASSET_EXPOSURE_MAP.get(asset_exposure.lower() if asset_exposure else None, 5.0)
rv = _recency_score(discovered_at)
Expand All @@ -131,6 +217,9 @@ def compute_risk_factors(
asset_exposure: Optional[str] = None,
discovered_at: Optional[datetime] = None,
confidence: Optional[float] = None,
exposure_context: Optional[str] = None,
business_criticality: Optional[str] = None,
severity_override: Optional[float] = None,
risk_score: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""
Expand All @@ -144,14 +233,33 @@ def compute_risk_factors(
- detail: short explanation sentence
"""
if risk_score is None:
risk_score = compute_risk_score(severity, exploitability, asset_exposure, discovered_at, confidence)
risk_score = compute_risk_score(
severity, exploitability, asset_exposure, discovered_at, confidence,
exposure_context=exposure_context,
business_criticality=business_criticality,
severity_override=severity_override,
)

sv = _severity_score(severity)
base_severity = _severity_score(severity)
sv = _contextual_severity_score(
base_severity,
exposure_context=exposure_context,
business_criticality=business_criticality,
custom_override=severity_override,
)
ev = _clamp(exploitability if exploitability is not None else 5.0)
av = ASSET_EXPOSURE_MAP.get(asset_exposure.lower() if asset_exposure else None, 5.0)
rv = _recency_score(discovered_at)
cv = _confidence_score(confidence)

# Build context information string
context_parts = []
if exposure_context:
context_parts.append(f"exposure: {exposure_context}")
if business_criticality:
context_parts.append(f"criticality: {business_criticality}")
context_str = " [" + ", ".join(context_parts) + "]" if context_parts else ""

factors = [
{
"factor": "severity",
Expand All @@ -160,7 +268,10 @@ def compute_risk_factors(
"score": round(sv, 1),
"weight": WEIGHTS["severity"],
"contribution": round(sv * WEIGHTS["severity"], 2),
"detail": f"Severity is {severity} ({sv:.1f}/10)",
"detail": f"Base severity {severity} ({base_severity:.1f}/10) adjusted to {sv:.1f}/10{context_str}",
"exposure_context": exposure_context,
"business_criticality": business_criticality,
"context_multiplier": round((sv / base_severity) if base_severity > 0 else 1.0, 2),
},
{
"factor": "exploitability",
Expand Down
12 changes: 6 additions & 6 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 114 additions & 0 deletions testing/backend/unit/test_risk_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,117 @@ async def test_backfill_idempotent(self, setup_test_environment):

row = await db.fetchone("SELECT risk_score FROM findings WHERE id = ?", (finding_id,))
assert row["risk_score"] == 5.0


class TestContextAwareSeverity:
"""Tests for context-aware severity calculation (issue #707)."""

def test_public_system_increases_severity(self):
"""Public-facing systems should have higher severity multiplier."""
# Private system
score_private = compute_risk_score(
"high", exposure_context="private"
)
# Public system
score_public = compute_risk_score(
"high", exposure_context="public"
)
assert score_public > score_private, "Public system should have higher score"

def test_internal_system_reduces_severity(self):
"""Internal systems should have lower severity multiplier."""
# Public system
score_public = compute_risk_score(
"high", exposure_context="public"
)
# Internal system
score_internal = compute_risk_score(
"high", exposure_context="internal"
)
assert score_internal < score_public, "Internal system should have lower score"

def test_business_criticality_multiplier(self):
"""Critical business functions should increase severity."""
# Low criticality
score_low = compute_risk_score(
"high", business_criticality="low"
)
# Critical business function
score_critical = compute_risk_score(
"high", business_criticality="critical"
)
assert score_critical > score_low, "Critical function should have higher score"

def test_combined_context_factors(self):
"""System exposure + business criticality should multiply together."""
# Public + critical
score_public_critical = compute_risk_score(
"high",
exposure_context="public",
business_criticality="critical"
)
# Private + low
score_private_low = compute_risk_score(
"high",
exposure_context="private",
business_criticality="low"
)
assert score_public_critical > score_private_low, "Public+critical should have highest score"

def test_severity_override_bypasses_context(self):
"""Custom severity override should bypass context calculation."""
score_with_context = compute_risk_score(
"low",
exposure_context="public",
business_criticality="critical"
)
score_with_override = compute_risk_score(
"low",
exposure_context="public",
business_criticality="critical",
severity_override=8.0
)
assert score_with_override > score_with_context, "Override should set fixed severity"
# The override bypasses context multipliers. With 8.0 severity (30% weight),
# the contribution is 8.0 × 0.30 = 2.4. With other defaults, total ~5.9.
assert score_with_override > 5.0, "Override of 8.0 should produce elevated score"

def test_risk_factors_include_context(self):
"""Risk factors should include exposure context and criticality info."""
factors = compute_risk_factors(
"high",
exposure_context="public",
business_criticality="critical"
)
severity_factor = next(f for f in factors if f["factor"] == "severity")
assert "exposure_context" in severity_factor, "Should include exposure context"
assert "business_criticality" in severity_factor, "Should include business criticality"
assert "context_multiplier" in severity_factor, "Should include context multiplier"
assert severity_factor["exposure_context"] == "public"
assert severity_factor["business_criticality"] == "critical"

def test_all_exposure_contexts_valid(self):
"""All exposure context types should produce valid scores."""
contexts = ["public", "internet_facing", "internal", "private"]
for context in contexts:
score = compute_risk_score("high", exposure_context=context)
assert 0.0 <= score <= 10.0, f"Score {score} invalid for context {context}"

def test_all_criticality_levels_valid(self):
"""All business criticality levels should produce valid scores."""
criticalities = ["critical", "high", "medium", "low"]
for criticality in criticalities:
score = compute_risk_score("high", business_criticality=criticality)
assert 0.0 <= score <= 10.0, f"Score {score} invalid for criticality {criticality}"

def test_context_details_in_explanation(self):
"""Factor detail should mention context adjustments."""
factors = compute_risk_factors(
"medium",
exposure_context="public",
business_criticality="high"
)
severity_factor = next(f for f in factors if f["factor"] == "severity")
detail = severity_factor["detail"]
assert "exposure" in detail.lower() or "adjusted" in detail.lower(), \
"Detail should explain context adjustment"
Loading