diff --git a/backend/core/views.py b/backend/core/views.py index 27ec8ae9ca..a2071816ba 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1908,6 +1908,304 @@ def sync_to_applied_controls(self, request, pk): {"changes": RiskScenarioReadSerializer(changes, many=True).data} ) + @action( + detail=True, + methods=["post"], + url_path="convert_to_quantitative", + ) + def convert_to_quantitative(self, request, pk): + """ + Convert a qualitative risk assessment to a quantitative risk study. + + Expected payload: + { + "probability_anchors": [{"index": 0, "value": 0.05}, ...], + "impact_anchors": [{"index": 0, "central_value": 25000}, ...], + "loss_threshold": 100000 + } + """ + from crq.models import ( + QuantitativeRiskStudy, + QuantitativeRiskScenario, + QuantitativeRiskHypothesis, + ) + + risk_assessment: RiskAssessment = self.get_object() + + # Check permissions + if not RoleAssignment.is_access_allowed( + user=request.user, + perm=Permission.objects.get(codename="add_quantitativeriskstudy"), + folder=risk_assessment.folder, + ): + return Response( + { + "detail": "You do not have permission to create quantitative risk studies in this domain." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # Validate payload + probability_anchors = request.data.get("probability_anchors", []) + impact_anchors = request.data.get("impact_anchors", []) + loss_threshold = request.data.get("loss_threshold") + + if not probability_anchors or not impact_anchors or loss_threshold is None: + return Response( + { + "detail": "Missing required fields: probability_anchors, impact_anchors, and loss_threshold" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate loss_threshold is a positive number + try: + loss_threshold = float(loss_threshold) + if loss_threshold <= 0: + return Response( + {"detail": "loss_threshold must be greater than 0"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (TypeError, ValueError): + return Response( + {"detail": "loss_threshold must be a valid number"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate and build probability mapping + probability_map = {} + try: + for item in probability_anchors: + idx = item["index"] + value = float(item["value"]) + if not (0 <= value <= 1): + return Response( + { + "detail": f"Probability value must be between 0 and 1, got {value} for index {idx}" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + probability_map[idx] = value + except (KeyError, TypeError, ValueError) as e: + logger.warning(f"Invalid probability anchor format: {e}") + return Response( + { + "detail": "Invalid probability anchor format. Each anchor must have 'index' and 'value' fields, with value between 0 and 1." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate and build impact mapping + # Using a narrow fixed ratio: roughly ±30% around central value + # LB = central_value * 0.7, UB = central_value * 1.43 + # This gives a spread factor of ~2x which minimizes overlap between adjacent levels + impact_map = {} + try: + for item in impact_anchors: + idx = item["index"] + central_value = float(item["central_value"]) + if central_value <= 0: + return Response( + { + "detail": f"Impact central value must be positive, got {central_value} for index {idx}" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + impact_map[idx] = { + "lb": central_value * 0.7, + "ub": central_value * 1.43, + } + except (KeyError, TypeError, ValueError) as e: + logger.warning(f"Invalid impact anchor format: {e}") + return Response( + { + "detail": "Invalid impact anchor format. Each anchor must have 'index' and 'central_value' fields, with central_value greater than 0." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Generate ref_id for the quantitative study + # If source has ref_id, append _QUANT, otherwise leave blank + quant_ref_id = ( + f"{risk_assessment.ref_id}_QUANT" if risk_assessment.ref_id else "" + ) + + # Create the quantitative risk study with [QUANT] prefix + quantitative_study = QuantitativeRiskStudy.objects.create( + name=f"[QUANT] {risk_assessment.name}", + description=f"Converted from qualitative risk assessment: {risk_assessment.name}\n\n{risk_assessment.description or ''}", + folder=risk_assessment.folder, + ref_id=quant_ref_id, + status=risk_assessment.status if risk_assessment.status else "planned", + loss_threshold=loss_threshold, + distribution_model="lognormal_ci90", + ) + + # Copy authors and reviewers + quantitative_study.authors.set(risk_assessment.authors.all()) + quantitative_study.reviewers.set(risk_assessment.reviewers.all()) + + scenarios_converted = 0 + scenarios_skipped = 0 + + # Convert each risk scenario to a quantitative risk scenario + for scenario in risk_assessment.risk_scenarios.all(): + # Skip scenarios without threats or assets + if not scenario.threats.exists() and not scenario.assets.exists(): + logger.info( + f"Skipping scenario '{scenario.name}' - no threats or assets associated" + ) + scenarios_skipped += 1 + continue + + # Check if we have current probability and impact + current_proba = scenario.current_proba + current_impact = scenario.current_impact + has_current = ( + current_proba is not None + and current_impact is not None + and current_proba in probability_map + and current_impact in impact_map + ) + + # Check if we have residual probability and impact + residual_proba = scenario.residual_proba + residual_impact = scenario.residual_impact + has_residual = ( + residual_proba is not None + and residual_impact is not None + and residual_proba in probability_map + and residual_impact in impact_map + ) + + # Skip if we don't have at least current values + if not has_current and not has_residual: + logger.info( + f"Skipping scenario '{scenario.name}' - no valid probability/impact values" + ) + scenarios_skipped += 1 + continue + + # Create quantitative risk scenario with same name and ref_id + quant_scenario = QuantitativeRiskScenario.objects.create( + quantitative_risk_study=quantitative_study, + name=scenario.name, + description=scenario.description or "", + ref_id=scenario.ref_id or "", + folder=risk_assessment.folder, + ) + + # Copy threats, assets, and owners + quant_scenario.threats.set(scenario.threats.all()) + quant_scenario.assets.set(scenario.assets.all()) + quant_scenario.vulnerabilities.set(scenario.vulnerabilities.all()) + quant_scenario.owner.set(scenario.owner.all()) + + # Delete the auto-created "baseline" current hypothesis + # (we'll create our own if needed) + QuantitativeRiskHypothesis.objects.filter( + quantitative_risk_scenario=quant_scenario, risk_stage="current" + ).delete() + + # Create current hypothesis if current values are set + if has_current: + probability_value = probability_map[current_proba] + impact_value = impact_map[current_impact] + + current_hypothesis = QuantitativeRiskHypothesis.objects.create( + quantitative_risk_scenario=quant_scenario, + name="current", + risk_stage="current", + ref_id=scenario.ref_id or "", + folder=risk_assessment.folder, + parameters={ + "probability": probability_value, + "impact": { + "distribution": "LOGNORMAL-CI90", + "lb": impact_value["lb"], + "ub": impact_value["ub"], + }, + }, + ) + + # Link to existing applied controls + if scenario.existing_applied_controls.exists(): + current_hypothesis.existing_applied_controls.set( + scenario.existing_applied_controls.all() + ) + current_hypothesis.save() + + # Run simulation for the current hypothesis + try: + current_hypothesis.run_simulation() + logger.info( + f"Simulation completed for current hypothesis: {scenario.name}" + ) + except Exception as e: + logger.error( + f"Failed to run simulation for scenario {scenario.name}: {str(e)}" + ) + + # Create residual hypothesis if residual values are set + if has_residual: + residual_probability_value = probability_map[residual_proba] + residual_impact_value = impact_map[residual_impact] + + residual_hypothesis = QuantitativeRiskHypothesis.objects.create( + quantitative_risk_scenario=quant_scenario, + name="residual", + risk_stage="residual", + is_selected=True, + ref_id=f"{scenario.ref_id}_RES" if scenario.ref_id else "", + folder=risk_assessment.folder, + parameters={ + "probability": residual_probability_value, + "impact": { + "distribution": "LOGNORMAL-CI90", + "lb": residual_impact_value["lb"], + "ub": residual_impact_value["ub"], + }, + }, + ) + + # Link to existing controls and added controls + if scenario.existing_applied_controls.exists(): + residual_hypothesis.existing_applied_controls.set( + scenario.existing_applied_controls.all() + ) + if scenario.applied_controls.exists(): + residual_hypothesis.added_applied_controls.set( + scenario.applied_controls.all() + ) + residual_hypothesis.save() + + # Run simulation for the residual hypothesis + try: + residual_hypothesis.run_simulation() + logger.info( + f"Simulation completed for residual hypothesis: {scenario.name}" + ) + except Exception as e: + logger.error( + f"Failed to run simulation for residual scenario {scenario.name}: {str(e)}" + ) + + scenarios_converted += 1 + + logger.info( + f"Conversion completed: {scenarios_converted} scenarios converted, {scenarios_skipped} skipped" + ) + + return Response( + { + "message": "Conversion successful", + "quantitative_risk_study_id": str(quantitative_study.id), + "scenarios_converted": scenarios_converted, + "scenarios_skipped": scenarios_skipped, + }, + status=status.HTTP_201_CREATED, + ) + def convert_date_to_timestamp(date): """ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 13706ce2a0..debfa6df92 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2044,7 +2044,7 @@ "removedControls": "Removed Controls", "expectedLossLowerBound": "Expected Loss Lower Bound (LB)", "expectedLossUpperBound": "Expected Loss Upper Bound (UB)", - "probabilityHelpText": "Estimated probability value between 0 and 1, based on the knowledge you have", + "probabilityHelpText": "Estimated probability between 0 and 1, based on the knowledge you have", "lowerBoundHelpText": "Loss in best case scenario. 5% of the time it could be LOWER than this.", "upperBoundHelpText": "Loss in worst case scenario. 5% of the time it will be HIGHER than this.", "simulationParameters": "Simulation Parameters", @@ -2271,6 +2271,8 @@ "addedControlsHelp": "What do you need to implement to reduce the risk.", "removedControlsHelp": "Useful to simulate cost-saving opportunities or inherent risk posture", "probabilityP": "Probability (P)", + "probabilityPercent": "Probability (%)", + "probabilityPercentHelpText": "Estimated probability between 0 and 100 (percentage), based on the knowledge you have", "expectedLossLowerBoundCurrency": "Expected Loss Lower Bound ({currency})", "expectedLossUpperBoundCurrency": "Expected Loss Upper Bound ({currency})", "retriggerAllSimulationsTitle": "Retrigger all simulations for this study including hypotheses, portfolio data, and risk tolerance curve", @@ -2574,5 +2576,32 @@ "webhookEndpointUrl": "Webhook Endpoint URL", "webhookEndpointUrlHelpText": "The URL where the application will receive incoming webhooks from the third-party service.", "createRemoteObject": "Create remote object", - "createRemoteObjectHelpText": "When enabled, a corresponding object will be created in the third-party service when this item is created." + "createRemoteObjectHelpText": "When enabled, a corresponding object will be created in the third-party service when this item is created.", + "convertToQuantitative": "Convert to Quantitative", + "convertToQuantitativeRisk": "Convert to Quantitative Risk Assessment", + "convertToQuantitativeRiskDescription": "Provide anchoring points to convert this qualitative risk assessment into a quantitative risk study with Monte Carlo simulations.", + "sourceRiskAssessment": "Source Risk Assessment", + "probabilityAnchoring": "Probability Anchoring", + "probabilityAnchoringDescription": "Map each probability level to a percentage value between 0 and 100 (e.g., 5 for 5% probability, 20 for 20%).", + "impactAnchoring": "Impact Anchoring", + "impactAnchoringDescription": "For each impact level, provide a typical financial loss value. The system will calculate bounds (±30% spread) for Monte Carlo simulations.", + "lowerBound": "Lower Bound", + "upperBound": "Upper Bound", + "lossThresholdDescription": "The maximum acceptable loss value for the quantitative risk study. This helps define your organization's risk tolerance.", + "converting": "Converting...", + "conversionSuccessful": "Conversion successful! Redirecting to the new quantitative risk study...", + "conversionFailed": "Conversion failed. Please check your input and try again.", + "pleaseProvideAtLeastOneProbabilityAnchor": "Please provide at least one probability anchor value.", + "pleaseProvideAtLeastOneImpactAnchor": "Please provide at least one impact anchor value.", + "pleaseProvideValidLossThreshold": "Please provide a valid loss threshold value greater than 0.", + "allProbabilityValuesMustBeProvided": "All probability values must be provided. Please fill in all probability anchors.", + "allImpactValuesMustBeProvided": "All impact values must be provided. Please fill in all impact anchors.", + "probabilityMustBeBetweenZeroAndOne": "Probability for '{level}' must be between 0 and 1 (exclusive). Please use a value like 0.05 or 0.9.", + "probabilityMustBeBetweenZeroAndHundred": "Probability for '{level}' must be between 0 and 100 (exclusive). Please use a value like 5 or 90.", + "probabilityValuesMustBeIncreasing": "Probability for '{current}' must be greater than '{previous}'. Values should increase across levels.", + "impactMustBeGreaterThanZero": "Impact for '{level}' must be greater than 0.", + "impactValuesMustBeIncreasing": "Impact for '{current}' must be greater than '{previous}'. Values should increase across levels.", + "runningMonteCarloSimulations": "Running Monte Carlo simulations for quantitative risk analysis. This may take a moment...", + "probabilityRequirements": "All values must be provided and between 0 and 100 (e.g., 5, 20, 50, 90). Values must increase from top to bottom.", + "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)." } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index e7fd23f873..feb307a345 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -2257,6 +2257,8 @@ "addedControlsHelp": "Ce que vous devez mettre en œuvre pour réduire le risque.", "removedControlsHelp": "Utile pour simuler des opportunités d'économies ou une posture de risque inhérente", "probabilityP": "Probabilité (P)", + "probabilityPercent": "Probabilité (%)", + "probabilityPercentHelpText": "Probabilité estimée entre 0 et 100 (pourcentage), basée sur les connaissances que vous avez", "expectedLossLowerBoundCurrency": "Borne inférieure de perte attendue ({currency})", "expectedLossUpperBoundCurrency": "Borne supérieure de perte attendue ({currency})", "retriggerAllSimulationsTitle": "Relancer toutes les simulations pour cette étude incluant les hypothèses, synthèse du portefeuille et la courbe de tolérance", @@ -2533,5 +2535,33 @@ "selectAuditToCompare": "Choisir un audit valide pour la comparaison", "showOverlapWithOtherFrameworks": "Afficher les superpositions avec d'autres référentiels", "tests": "Tests", - "withDifferences": "avec des différences" + "withDifferences": "avec des différences", + "convertToQuantitative": "Convertir en quantitative", + "convertToQuantitativeRisk": "Convertir en analyse de risque quantitative", + "convertToQuantitativeRiskDescription": "Fournissez des points d'ancrage pour convertir cette analyse de risque qualitative en étude de risque quantitative avec simulations Monte Carlo.", + "sourceRiskAssessment": "Analyse de risque source", + "probabilityAnchoring": "Ancrage des probabilités", + "probabilityAnchoringDescription": "Associez chaque niveau de probabilité à une valeur en pourcentage entre 0 et 100 (par exemple, 5 pour une probabilité de 5%, 20 pour 20%).", + "impactAnchoring": "Ancrage des impacts", + "impactAnchoringDescription": "Pour chaque niveau d'impact, fournissez une valeur de perte financière typique. Le système calculera les bornes (marge de ±30%) pour les simulations Monte Carlo.", + "lowerBound": "Borne inférieure", + "upperBound": "Borne supérieure", + "lossThreshold": "Seuil de perte", + "lossThresholdDescription": "La valeur maximale de perte acceptable pour l'étude de risque quantitative. Cela aide à définir la tolérance au risque de votre organisation.", + "converting": "Conversion en cours...", + "conversionSuccessful": "Conversion réussie ! Redirection vers la nouvelle étude de risque quantitative...", + "conversionFailed": "La conversion a échoué. Veuillez vérifier vos données et réessayer.", + "pleaseProvideAtLeastOneProbabilityAnchor": "Veuillez fournir au moins une valeur d'ancrage de probabilité.", + "pleaseProvideAtLeastOneImpactAnchor": "Veuillez fournir au moins une valeur d'ancrage d'impact.", + "pleaseProvideValidLossThreshold": "Veuillez fournir une valeur de seuil de perte valide supérieure à 0.", + "allProbabilityValuesMustBeProvided": "Toutes les valeurs de probabilité doivent être fournies. Veuillez remplir tous les ancrages de probabilité.", + "allImpactValuesMustBeProvided": "Toutes les valeurs d'impact doivent être fournies. Veuillez remplir tous les ancrages d'impact.", + "probabilityMustBeBetweenZeroAndOne": "La probabilité pour '{level}' doit être comprise entre 0 et 1 (exclusif). Veuillez utiliser une valeur comme 0,05 ou 0,9.", + "probabilityMustBeBetweenZeroAndHundred": "La probabilité pour '{level}' doit être comprise entre 0 et 100 (exclusif). Veuillez utiliser une valeur comme 5 ou 90.", + "probabilityValuesMustBeIncreasing": "La probabilité pour '{current}' doit être supérieure à '{previous}'. Les valeurs doivent augmenter entre les niveaux.", + "impactMustBeGreaterThanZero": "L'impact pour '{level}' doit être supérieur à 0.", + "impactValuesMustBeIncreasing": "L'impact pour '{current}' doit être supérieur à '{previous}'. Les valeurs doivent augmenter entre les niveaux.", + "runningMonteCarloSimulations": "Exécution des simulations Monte Carlo pour l'analyse de risque quantitative. Cela peut prendre un moment...", + "probabilityRequirements": "Toutes les valeurs doivent être fournies et comprises entre 0 et 100 (par exemple, 5, 20, 50, 90). Les valeurs doivent augmenter de haut en bas.", + "impactRequirements": "Toutes les valeurs doivent être fournies et supérieures à 0. Les valeurs doivent augmenter de haut en bas (par exemple, Mineur : 5 000 → Majeur : 50 000 → Critique : 500 000)." } diff --git a/frontend/src/lib/components/Forms/ModelForm/QuantitativeRiskHypothesisForm.svelte b/frontend/src/lib/components/Forms/ModelForm/QuantitativeRiskHypothesisForm.svelte index 90efd6b70f..29de843053 100644 --- a/frontend/src/lib/components/Forms/ModelForm/QuantitativeRiskHypothesisForm.svelte +++ b/frontend/src/lib/components/Forms/ModelForm/QuantitativeRiskHypothesisForm.svelte @@ -37,6 +37,36 @@ // Declare form store at top level const formStore = form.form; + + // Local state for percentage display (0-100) + let probabilityPercent = $state(undefined); + let initialized = false; + + // One-time initialization: convert probability to percentage when form loads + $effect(() => { + if (initialized) return; + + const prob = $formStore.probability; + if (prob !== undefined && prob !== null && typeof prob === 'number') { + probabilityPercent = Math.round(prob * 10000) / 100; // Convert 0-1 to 0-100 with 2 decimals + } + initialized = true; // Always mark as initialized, even for new forms + }); + + // Only sync percentage → probability (one direction) + $effect(() => { + if (!initialized) return; + + if ( + probabilityPercent !== undefined && + probabilityPercent !== null && + typeof probabilityPercent === 'number' + ) { + $formStore.probability = Math.round(probabilityPercent * 100) / 10000; // Convert 0-100 to 0-1 + } else { + $formStore.probability = undefined; + } + }); - + + + + +
+ + + {#if m.probabilityPercentHelpText()} + + {/if} +
{/if} + + + {m.convertToQuantitative()} + diff --git a/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/convert-to-quantitative/+page.server.ts b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/convert-to-quantitative/+page.server.ts new file mode 100644 index 0000000000..4a2ea4f9ae --- /dev/null +++ b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/convert-to-quantitative/+page.server.ts @@ -0,0 +1,53 @@ +import { BASE_API_URL } from '$lib/utils/constants'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + // Load is handled by parent layout + return {}; +}; + +export const actions: Actions = { + default: async ({ request, fetch, params }) => { + const formData = await request.formData(); + const data = JSON.parse(formData.get('data') as string); + + try { + const response = await fetch( + `${BASE_API_URL}/risk-assessments/${params.id}/convert_to_quantitative/`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const error = await response.json(); + return fail(response.status, { + error: error.detail || 'Conversion failed', + data + }); + } + + const result = await response.json(); + + // Return the result with the new study ID for client-side redirect + return { + success: true, + quantitative_risk_study_id: result.quantitative_risk_study_id, + scenarios_converted: result.scenarios_converted, + scenarios_skipped: result.scenarios_skipped, + message: result.message + }; + } catch (error) { + console.error('Conversion error:', error); + return fail(500, { + error: 'An unexpected error occurred during conversion', + data + }); + } + } +}; diff --git a/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/convert-to-quantitative/+page.svelte b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/convert-to-quantitative/+page.svelte new file mode 100644 index 0000000000..13dc262f0f --- /dev/null +++ b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/convert-to-quantitative/+page.svelte @@ -0,0 +1,401 @@ + + +
+ + {#if isSubmitting} +
+
+ +

{m.converting()}

+

+ {m.runningMonteCarloSimulations()} +

+
+
+ {/if} + +
+
+

{m.convertToQuantitativeRisk()}

+

+ {m.convertToQuantitativeRiskDescription()} +

+
+ +
+ +
+

{m.sourceRiskAssessment()}

+

+ {m.name()}: + {risk_assessment.name} +

+

+ {m.domain()}: + {risk_assessment.folder.str} +

+

+ {m.riskMatrix()}: + {risk_assessment.risk_matrix.name} +

+
+ + +
+

{m.probabilityAnchoring()}

+

{m.probabilityAnchoringDescription()}

+
+ +
+ {m.requirements()} + {m.probabilityRequirements()} +
+
+
+ {#each probabilityOptions as prob, index} +
+
+ {index + 1} +
+ {#if prob.hexcolor} +
+ {/if} + + +
+ {/each} +
+
+ + +
+

{m.impactAnchoring()}

+

{m.impactAnchoringDescription()}

+
+ +
+ {m.requirements()} + {m.impactRequirements()} +
+
+
+ {#each impactOptions as impact, index} +
+
+ {index + 1} +
+ {#if impact.hexcolor} +
+ {/if} + + +
+ {/each} +
+
+ + +
+

{m.lossThreshold()}

+

{m.lossThresholdDescription()}

+ +
+ + +
{ + const payload = validateAndPrepareData(); + if (!payload) { + return async ({ update }) => { + await update({ reset: false }); + }; + } + + isSubmitting = true; + + return async ({ result, update }) => { + await update(); + if (result.type !== 'success') { + isSubmitting = false; + } + }; + }} + > + +
+ + +
+
+
+
+