From 5ccf4caf69655944e8f19b7754156896f2ea14dc Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Tue, 16 Jun 2026 21:21:51 +0530 Subject: [PATCH 1/3] feat: implement context-aware severity calculation for risk assessment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses issue #707: Severity ratings now account for system exposure context and business criticality, providing more accurate prioritization beyond raw CVSS scores. ## Key Changes ### 1. Exposure Context Factors - public (1.5x multiplier) - public-facing systems - internet_facing (1.3x) - internet-accessible systems - internal (0.8x) - internal-only systems - private (0.6x) - development/private systems ### 2. Business Criticality Multipliers - critical (1.5x) - critical business function - high (1.25x) - important business function - medium (1.0x) - standard function (baseline) - low (0.8x) - non-critical function ### 3. Calculation Formula contextual_severity = base_severity × exposure_factor × criticality_factor ### 4. Custom Override severity_override parameter allows manual adjustment when context calculation doesn't match operational risk assessment. ### 5. Enhanced Risk Factors - Factor details now show context-adjusted severity - New fields: exposure_context, business_criticality, context_multiplier - Full traceability of severity adjustments ## Testing Added 9 comprehensive tests validating: - Public vs private system scoring differences - Business criticality impact - Combined context factor multiplication - Override mechanism - All valid context/criticality combinations - Factor explanation accuracy --- backend/secuscan/risk_scoring.py | 119 +++++++++++++++++++++- testing/backend/unit/test_risk_scoring.py | 113 ++++++++++++++++++++ 2 files changed, 228 insertions(+), 4 deletions(-) diff --git a/backend/secuscan/risk_scoring.py b/backend/secuscan/risk_scoring.py index 4166a1a91..ef98d7ce2 100644 --- a/backend/secuscan/risk_scoring.py +++ b/backend/secuscan/risk_scoring.py @@ -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, @@ -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 # --------------------------------------------------------------------------- @@ -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]. @@ -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) @@ -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]]: """ @@ -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", @@ -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", diff --git a/testing/backend/unit/test_risk_scoring.py b/testing/backend/unit/test_risk_scoring.py index 820d7978e..8b86f9cd7 100644 --- a/testing/backend/unit/test_risk_scoring.py +++ b/testing/backend/unit/test_risk_scoring.py @@ -352,3 +352,116 @@ 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 affects the severity component weight + assert score_with_override > 7.0, "Override of 8.0 should produce high 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" From 4df3ed34ba502d0553f916930ace923f779b5630 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Thu, 18 Jun 2026 20:22:47 +0530 Subject: [PATCH 2/3] fix: adjust severity override test assertion to match weighted score reality The test expected score_with_override > 7.0 when using severity_override=8.0, but this unrealistic given that severity is only 30% weight in the composite score calculation. With 8.0 severity (30% weight) plus default values for other factors (exploitability, asset_exposure, recency, confidence), the composite score is ~5.9, not > 7.0. Adjusted the assertion to > 5.0 which accurately tests that the override produces an elevated score without unrealistic expectations about the weighted aggregate. --- testing/backend/unit/test_risk_scoring.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/backend/unit/test_risk_scoring.py b/testing/backend/unit/test_risk_scoring.py index 8b86f9cd7..fc9b22c86 100644 --- a/testing/backend/unit/test_risk_scoring.py +++ b/testing/backend/unit/test_risk_scoring.py @@ -423,8 +423,9 @@ def test_severity_override_bypasses_context(self): severity_override=8.0 ) assert score_with_override > score_with_context, "Override should set fixed severity" - # The override affects the severity component weight - assert score_with_override > 7.0, "Override of 8.0 should produce high score" + # 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.""" From bd87bdde9ce359c3f532277c5c7ee6628a9be110 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Thu, 18 Jun 2026 21:17:00 +0530 Subject: [PATCH 3/3] fix: resolve npm audit vulnerabilities in frontend dependencies - Update dompurify to >=3.4.11 (fixes GHSA-cmwh-pvxp-8882) - Update undici to >=7.28.0 (fixes GHSA-vmh5-mc38-953g and GHSA-pr7r-676h-xcf6) - All npm audit checks now pass - All frontend tests, typecheck, and build succeed --- frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8dd006c19..6b85eeb53 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2662,9 +2662,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -4545,9 +4545,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": {