Skip to content

Commit c3f9083

Browse files
authored
feat(exp): convert qualitative risk assessments to quantitative ones (#2814)
* feat: convert qualitative risk assessments to quantitative ones * more narrow spread * fr translations * manage crq hypothesis as percentage * fixup * colors on the assistant * coderabbit suggestions * fixup
1 parent 66fa439 commit c3f9083

File tree

8 files changed

+877
-17
lines changed

8 files changed

+877
-17
lines changed

backend/core/views.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,304 @@ def sync_to_applied_controls(self, request, pk):
19081908
{"changes": RiskScenarioReadSerializer(changes, many=True).data}
19091909
)
19101910

1911+
@action(
1912+
detail=True,
1913+
methods=["post"],
1914+
url_path="convert_to_quantitative",
1915+
)
1916+
def convert_to_quantitative(self, request, pk):
1917+
"""
1918+
Convert a qualitative risk assessment to a quantitative risk study.
1919+
1920+
Expected payload:
1921+
{
1922+
"probability_anchors": [{"index": 0, "value": 0.05}, ...],
1923+
"impact_anchors": [{"index": 0, "central_value": 25000}, ...],
1924+
"loss_threshold": 100000
1925+
}
1926+
"""
1927+
from crq.models import (
1928+
QuantitativeRiskStudy,
1929+
QuantitativeRiskScenario,
1930+
QuantitativeRiskHypothesis,
1931+
)
1932+
1933+
risk_assessment: RiskAssessment = self.get_object()
1934+
1935+
# Check permissions
1936+
if not RoleAssignment.is_access_allowed(
1937+
user=request.user,
1938+
perm=Permission.objects.get(codename="add_quantitativeriskstudy"),
1939+
folder=risk_assessment.folder,
1940+
):
1941+
return Response(
1942+
{
1943+
"detail": "You do not have permission to create quantitative risk studies in this domain."
1944+
},
1945+
status=status.HTTP_403_FORBIDDEN,
1946+
)
1947+
1948+
# Validate payload
1949+
probability_anchors = request.data.get("probability_anchors", [])
1950+
impact_anchors = request.data.get("impact_anchors", [])
1951+
loss_threshold = request.data.get("loss_threshold")
1952+
1953+
if not probability_anchors or not impact_anchors or loss_threshold is None:
1954+
return Response(
1955+
{
1956+
"detail": "Missing required fields: probability_anchors, impact_anchors, and loss_threshold"
1957+
},
1958+
status=status.HTTP_400_BAD_REQUEST,
1959+
)
1960+
1961+
# Validate loss_threshold is a positive number
1962+
try:
1963+
loss_threshold = float(loss_threshold)
1964+
if loss_threshold <= 0:
1965+
return Response(
1966+
{"detail": "loss_threshold must be greater than 0"},
1967+
status=status.HTTP_400_BAD_REQUEST,
1968+
)
1969+
except (TypeError, ValueError):
1970+
return Response(
1971+
{"detail": "loss_threshold must be a valid number"},
1972+
status=status.HTTP_400_BAD_REQUEST,
1973+
)
1974+
1975+
# Validate and build probability mapping
1976+
probability_map = {}
1977+
try:
1978+
for item in probability_anchors:
1979+
idx = item["index"]
1980+
value = float(item["value"])
1981+
if not (0 <= value <= 1):
1982+
return Response(
1983+
{
1984+
"detail": f"Probability value must be between 0 and 1, got {value} for index {idx}"
1985+
},
1986+
status=status.HTTP_400_BAD_REQUEST,
1987+
)
1988+
probability_map[idx] = value
1989+
except (KeyError, TypeError, ValueError) as e:
1990+
logger.warning(f"Invalid probability anchor format: {e}")
1991+
return Response(
1992+
{
1993+
"detail": "Invalid probability anchor format. Each anchor must have 'index' and 'value' fields, with value between 0 and 1."
1994+
},
1995+
status=status.HTTP_400_BAD_REQUEST,
1996+
)
1997+
1998+
# Validate and build impact mapping
1999+
# Using a narrow fixed ratio: roughly ±30% around central value
2000+
# LB = central_value * 0.7, UB = central_value * 1.43
2001+
# This gives a spread factor of ~2x which minimizes overlap between adjacent levels
2002+
impact_map = {}
2003+
try:
2004+
for item in impact_anchors:
2005+
idx = item["index"]
2006+
central_value = float(item["central_value"])
2007+
if central_value <= 0:
2008+
return Response(
2009+
{
2010+
"detail": f"Impact central value must be positive, got {central_value} for index {idx}"
2011+
},
2012+
status=status.HTTP_400_BAD_REQUEST,
2013+
)
2014+
impact_map[idx] = {
2015+
"lb": central_value * 0.7,
2016+
"ub": central_value * 1.43,
2017+
}
2018+
except (KeyError, TypeError, ValueError) as e:
2019+
logger.warning(f"Invalid impact anchor format: {e}")
2020+
return Response(
2021+
{
2022+
"detail": "Invalid impact anchor format. Each anchor must have 'index' and 'central_value' fields, with central_value greater than 0."
2023+
},
2024+
status=status.HTTP_400_BAD_REQUEST,
2025+
)
2026+
2027+
# Generate ref_id for the quantitative study
2028+
# If source has ref_id, append _QUANT, otherwise leave blank
2029+
quant_ref_id = (
2030+
f"{risk_assessment.ref_id}_QUANT" if risk_assessment.ref_id else ""
2031+
)
2032+
2033+
# Create the quantitative risk study with [QUANT] prefix
2034+
quantitative_study = QuantitativeRiskStudy.objects.create(
2035+
name=f"[QUANT] {risk_assessment.name}",
2036+
description=f"Converted from qualitative risk assessment: {risk_assessment.name}\n\n{risk_assessment.description or ''}",
2037+
folder=risk_assessment.folder,
2038+
ref_id=quant_ref_id,
2039+
status=risk_assessment.status if risk_assessment.status else "planned",
2040+
loss_threshold=loss_threshold,
2041+
distribution_model="lognormal_ci90",
2042+
)
2043+
2044+
# Copy authors and reviewers
2045+
quantitative_study.authors.set(risk_assessment.authors.all())
2046+
quantitative_study.reviewers.set(risk_assessment.reviewers.all())
2047+
2048+
scenarios_converted = 0
2049+
scenarios_skipped = 0
2050+
2051+
# Convert each risk scenario to a quantitative risk scenario
2052+
for scenario in risk_assessment.risk_scenarios.all():
2053+
# Skip scenarios without threats or assets
2054+
if not scenario.threats.exists() and not scenario.assets.exists():
2055+
logger.info(
2056+
f"Skipping scenario '{scenario.name}' - no threats or assets associated"
2057+
)
2058+
scenarios_skipped += 1
2059+
continue
2060+
2061+
# Check if we have current probability and impact
2062+
current_proba = scenario.current_proba
2063+
current_impact = scenario.current_impact
2064+
has_current = (
2065+
current_proba is not None
2066+
and current_impact is not None
2067+
and current_proba in probability_map
2068+
and current_impact in impact_map
2069+
)
2070+
2071+
# Check if we have residual probability and impact
2072+
residual_proba = scenario.residual_proba
2073+
residual_impact = scenario.residual_impact
2074+
has_residual = (
2075+
residual_proba is not None
2076+
and residual_impact is not None
2077+
and residual_proba in probability_map
2078+
and residual_impact in impact_map
2079+
)
2080+
2081+
# Skip if we don't have at least current values
2082+
if not has_current and not has_residual:
2083+
logger.info(
2084+
f"Skipping scenario '{scenario.name}' - no valid probability/impact values"
2085+
)
2086+
scenarios_skipped += 1
2087+
continue
2088+
2089+
# Create quantitative risk scenario with same name and ref_id
2090+
quant_scenario = QuantitativeRiskScenario.objects.create(
2091+
quantitative_risk_study=quantitative_study,
2092+
name=scenario.name,
2093+
description=scenario.description or "",
2094+
ref_id=scenario.ref_id or "",
2095+
folder=risk_assessment.folder,
2096+
)
2097+
2098+
# Copy threats, assets, and owners
2099+
quant_scenario.threats.set(scenario.threats.all())
2100+
quant_scenario.assets.set(scenario.assets.all())
2101+
quant_scenario.vulnerabilities.set(scenario.vulnerabilities.all())
2102+
quant_scenario.owner.set(scenario.owner.all())
2103+
2104+
# Delete the auto-created "baseline" current hypothesis
2105+
# (we'll create our own if needed)
2106+
QuantitativeRiskHypothesis.objects.filter(
2107+
quantitative_risk_scenario=quant_scenario, risk_stage="current"
2108+
).delete()
2109+
2110+
# Create current hypothesis if current values are set
2111+
if has_current:
2112+
probability_value = probability_map[current_proba]
2113+
impact_value = impact_map[current_impact]
2114+
2115+
current_hypothesis = QuantitativeRiskHypothesis.objects.create(
2116+
quantitative_risk_scenario=quant_scenario,
2117+
name="current",
2118+
risk_stage="current",
2119+
ref_id=scenario.ref_id or "",
2120+
folder=risk_assessment.folder,
2121+
parameters={
2122+
"probability": probability_value,
2123+
"impact": {
2124+
"distribution": "LOGNORMAL-CI90",
2125+
"lb": impact_value["lb"],
2126+
"ub": impact_value["ub"],
2127+
},
2128+
},
2129+
)
2130+
2131+
# Link to existing applied controls
2132+
if scenario.existing_applied_controls.exists():
2133+
current_hypothesis.existing_applied_controls.set(
2134+
scenario.existing_applied_controls.all()
2135+
)
2136+
current_hypothesis.save()
2137+
2138+
# Run simulation for the current hypothesis
2139+
try:
2140+
current_hypothesis.run_simulation()
2141+
logger.info(
2142+
f"Simulation completed for current hypothesis: {scenario.name}"
2143+
)
2144+
except Exception as e:
2145+
logger.error(
2146+
f"Failed to run simulation for scenario {scenario.name}: {str(e)}"
2147+
)
2148+
2149+
# Create residual hypothesis if residual values are set
2150+
if has_residual:
2151+
residual_probability_value = probability_map[residual_proba]
2152+
residual_impact_value = impact_map[residual_impact]
2153+
2154+
residual_hypothesis = QuantitativeRiskHypothesis.objects.create(
2155+
quantitative_risk_scenario=quant_scenario,
2156+
name="residual",
2157+
risk_stage="residual",
2158+
is_selected=True,
2159+
ref_id=f"{scenario.ref_id}_RES" if scenario.ref_id else "",
2160+
folder=risk_assessment.folder,
2161+
parameters={
2162+
"probability": residual_probability_value,
2163+
"impact": {
2164+
"distribution": "LOGNORMAL-CI90",
2165+
"lb": residual_impact_value["lb"],
2166+
"ub": residual_impact_value["ub"],
2167+
},
2168+
},
2169+
)
2170+
2171+
# Link to existing controls and added controls
2172+
if scenario.existing_applied_controls.exists():
2173+
residual_hypothesis.existing_applied_controls.set(
2174+
scenario.existing_applied_controls.all()
2175+
)
2176+
if scenario.applied_controls.exists():
2177+
residual_hypothesis.added_applied_controls.set(
2178+
scenario.applied_controls.all()
2179+
)
2180+
residual_hypothesis.save()
2181+
2182+
# Run simulation for the residual hypothesis
2183+
try:
2184+
residual_hypothesis.run_simulation()
2185+
logger.info(
2186+
f"Simulation completed for residual hypothesis: {scenario.name}"
2187+
)
2188+
except Exception as e:
2189+
logger.error(
2190+
f"Failed to run simulation for residual scenario {scenario.name}: {str(e)}"
2191+
)
2192+
2193+
scenarios_converted += 1
2194+
2195+
logger.info(
2196+
f"Conversion completed: {scenarios_converted} scenarios converted, {scenarios_skipped} skipped"
2197+
)
2198+
2199+
return Response(
2200+
{
2201+
"message": "Conversion successful",
2202+
"quantitative_risk_study_id": str(quantitative_study.id),
2203+
"scenarios_converted": scenarios_converted,
2204+
"scenarios_skipped": scenarios_skipped,
2205+
},
2206+
status=status.HTTP_201_CREATED,
2207+
)
2208+
19112209

19122210
def convert_date_to_timestamp(date):
19132211
"""

frontend/messages/en.json

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,7 +2044,7 @@
20442044
"removedControls": "Removed Controls",
20452045
"expectedLossLowerBound": "Expected Loss Lower Bound (LB)",
20462046
"expectedLossUpperBound": "Expected Loss Upper Bound (UB)",
2047-
"probabilityHelpText": "Estimated probability value between 0 and 1, based on the knowledge you have",
2047+
"probabilityHelpText": "Estimated probability between 0 and 1, based on the knowledge you have",
20482048
"lowerBoundHelpText": "Loss in best case scenario. 5% of the time it could be LOWER than this.",
20492049
"upperBoundHelpText": "Loss in worst case scenario. 5% of the time it will be HIGHER than this.",
20502050
"simulationParameters": "Simulation Parameters",
@@ -2271,6 +2271,8 @@
22712271
"addedControlsHelp": "What do you need to implement to reduce the risk.",
22722272
"removedControlsHelp": "Useful to simulate cost-saving opportunities or inherent risk posture",
22732273
"probabilityP": "Probability (P)",
2274+
"probabilityPercent": "Probability (%)",
2275+
"probabilityPercentHelpText": "Estimated probability between 0 and 100 (percentage), based on the knowledge you have",
22742276
"expectedLossLowerBoundCurrency": "Expected Loss Lower Bound ({currency})",
22752277
"expectedLossUpperBoundCurrency": "Expected Loss Upper Bound ({currency})",
22762278
"retriggerAllSimulationsTitle": "Retrigger all simulations for this study including hypotheses, portfolio data, and risk tolerance curve",
@@ -2574,5 +2576,32 @@
25742576
"webhookEndpointUrl": "Webhook Endpoint URL",
25752577
"webhookEndpointUrlHelpText": "The URL where the application will receive incoming webhooks from the third-party service.",
25762578
"createRemoteObject": "Create remote object",
2577-
"createRemoteObjectHelpText": "When enabled, a corresponding object will be created in the third-party service when this item is created."
2579+
"createRemoteObjectHelpText": "When enabled, a corresponding object will be created in the third-party service when this item is created.",
2580+
"convertToQuantitative": "Convert to Quantitative",
2581+
"convertToQuantitativeRisk": "Convert to Quantitative Risk Assessment",
2582+
"convertToQuantitativeRiskDescription": "Provide anchoring points to convert this qualitative risk assessment into a quantitative risk study with Monte Carlo simulations.",
2583+
"sourceRiskAssessment": "Source Risk Assessment",
2584+
"probabilityAnchoring": "Probability Anchoring",
2585+
"probabilityAnchoringDescription": "Map each probability level to a percentage value between 0 and 100 (e.g., 5 for 5% probability, 20 for 20%).",
2586+
"impactAnchoring": "Impact Anchoring",
2587+
"impactAnchoringDescription": "For each impact level, provide a typical financial loss value. The system will calculate bounds (±30% spread) for Monte Carlo simulations.",
2588+
"lowerBound": "Lower Bound",
2589+
"upperBound": "Upper Bound",
2590+
"lossThresholdDescription": "The maximum acceptable loss value for the quantitative risk study. This helps define your organization's risk tolerance.",
2591+
"converting": "Converting...",
2592+
"conversionSuccessful": "Conversion successful! Redirecting to the new quantitative risk study...",
2593+
"conversionFailed": "Conversion failed. Please check your input and try again.",
2594+
"pleaseProvideAtLeastOneProbabilityAnchor": "Please provide at least one probability anchor value.",
2595+
"pleaseProvideAtLeastOneImpactAnchor": "Please provide at least one impact anchor value.",
2596+
"pleaseProvideValidLossThreshold": "Please provide a valid loss threshold value greater than 0.",
2597+
"allProbabilityValuesMustBeProvided": "All probability values must be provided. Please fill in all probability anchors.",
2598+
"allImpactValuesMustBeProvided": "All impact values must be provided. Please fill in all impact anchors.",
2599+
"probabilityMustBeBetweenZeroAndOne": "Probability for '{level}' must be between 0 and 1 (exclusive). Please use a value like 0.05 or 0.9.",
2600+
"probabilityMustBeBetweenZeroAndHundred": "Probability for '{level}' must be between 0 and 100 (exclusive). Please use a value like 5 or 90.",
2601+
"probabilityValuesMustBeIncreasing": "Probability for '{current}' must be greater than '{previous}'. Values should increase across levels.",
2602+
"impactMustBeGreaterThanZero": "Impact for '{level}' must be greater than 0.",
2603+
"impactValuesMustBeIncreasing": "Impact for '{current}' must be greater than '{previous}'. Values should increase across levels.",
2604+
"runningMonteCarloSimulations": "Running Monte Carlo simulations for quantitative risk analysis. This may take a moment...",
2605+
"probabilityRequirements": "All values must be provided and between 0 and 100 (e.g., 5, 20, 50, 90). Values must increase from top to bottom.",
2606+
"impactRequirements": "All values must be provided and greater than 0. Values must increase from top to bottom (e.g., Minor: 5,000 → Major: 50,000 → Critical: 500,000)."
25782607
}

0 commit comments

Comments
 (0)