diff --git a/docs/content/supported_tools/parsers/file/iriusrisk.md b/docs/content/supported_tools/parsers/file/iriusrisk.md new file mode 100644 index 00000000000..071d9347d33 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/iriusrisk.md @@ -0,0 +1,149 @@ +--- +title: "IriusRisk Threats Scan" +toc_hide: true +--- + +The [IriusRisk](https://www.iriusrisk.com/) parser for DefectDojo supports imports from CSV format. This document details the parsing of IriusRisk threat model CSV exports into DefectDojo field mappings, unmapped fields, and location of each field's parsing code for easier troubleshooting and analysis. + +## Supported File Types + +The IriusRisk parser accepts CSV file format. To generate this file from IriusRisk: + +1. Log into your IriusRisk console +2. Navigate to the project containing your threat model +3. Export the threats as CSV +4. Save the file with a `.csv` extension +5. Upload to DefectDojo using the "IriusRisk Threats Scan" scan type + +## Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- cwe +- line +- file_path +- description + +The parser also populates `unique_id_from_tool` with a SHA-256 hash of the Component, Threat, and Risk Response fields, providing an additional layer of deduplication across reimports. + +### Sample Scan Data + +Sample IriusRisk scans can be found in the [sample scan data folder](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/iriusrisk). + +## Link To Tool + +- [IriusRisk](https://www.iriusrisk.com/) +- [IriusRisk Documentation](https://support.iriusrisk.com/) + +## CSV Format (Threat Model Export) + +### Total Fields in CSV + +- Total data fields: 14 +- Total data fields parsed: 14 +- Total data fields NOT parsed: 0 + +### CSV Format Field Mapping Details + +
+Click to expand Field Mapping Table + +| Source Field | DefectDojo Field | Parser Line # | Notes | +| ------------------------ | -------------------- | ------------- | --------------------------------------------------------------------- | +| Threat | title | 51 | Truncated to 150 characters with "..." suffix if longer | +| Current Risk | severity | 53 | Mapped from IriusRisk risk levels to DefectDojo severity levels | +| Component | component_name | 95 | The affected asset or component from the threat model | +| Threat | description | 57 | Full threat text included as first line of structured description | +| Component | description | 58 | Included in structured description block | +| Use case | description | 59 | Threat category included in structured description | +| Source | description | 60 | Origin of the threat included in structured description | +| Inherent Risk | description | 61 | Pre-control risk level included in structured description | +| Current Risk | description | 62 | Current risk level included in structured description | +| Projected Risk | description | 63 | Post-mitigation risk level included in structured description | +| Countermeasure progress | description | 64 | Percentage complete included in structured description | +| Weakness tests | description | 65 | Test status included in structured description | +| Countermeasure tests | description | 66 | Test status included in structured description | +| Owner | description | 68-69 | Conditionally appended to description only when present | +| STRIDE-LM | description | 70-71 | Conditionally appended to description only when present | +| Risk Response | mitigation | 94 | Mitigation status percentages from IriusRisk | +| MITRE reference | cwe | 82-85 | When value matches CWE-NNN pattern, integer is extracted to cwe field | +| MITRE reference | references | 86-87 | When value does not match CWE pattern, stored as references | +| Component + Threat + Risk Response | unique_id_from_tool | 74-77 | SHA-256 hash used for deduplication across reimports | + +
+ +### Additional Finding Field Settings (CSV Format) + +
+Click to expand Additional Settings Table + +| Finding Field | Default Value | Parser Line # | Notes | +| ---------------- | -------------------------------- | ------------- | ----------------------------------------------------------- | +| static_finding | False | 97 | Threat model data is neither static nor dynamic analysis | +| dynamic_finding | False | 98 | Threat model data is neither static nor dynamic analysis | +| active | True (False when "Very low") | 96 | Set to False when Current Risk is "Very low" (fully mitigated) | +| unique_id_from_tool | SHA-256 hash | 99 | Hash of Component, Threat, and Risk Response | + +
+ +## Special Processing Notes + +### Status Conversion + +IriusRisk uses a five-level risk scale that is mapped to DefectDojo severity levels (lines 8-14): + +- `Critical` → Critical +- `High` → High +- `Medium` → Medium +- `Low` → Low +- `Very low` → Info + +Any unrecognized risk value defaults to Info (line 53). The mapping uses the "Current Risk" column, which reflects the risk level accounting for existing controls and represents the most accurate current exposure. + +### Title Format + +Finding titles are derived from the "Threat" column (line 51). Threat descriptions longer than 150 characters are truncated to 147 characters with a "..." suffix appended. Shorter threat texts are used as-is without modification. + +### Description Construction + +The parser constructs a structured markdown description containing all relevant CSV fields (lines 56-72): + +1. Full threat text (untruncated, regardless of title truncation) +2. Component name +3. Use case (threat category, e.g., "Elevation of Privilege", "Networking") +4. Source (e.g., "Created by Rules Engine") +5. Inherent Risk (pre-control risk level) +6. Current Risk (risk with existing controls) +7. Projected Risk (risk after planned mitigations) +8. Countermeasure Progress (percentage complete) +9. Weakness Tests (test status) +10. Countermeasure Tests (test status) +11. Owner (conditionally included only when the field contains a value) +12. STRIDE-LM (conditionally included only when the field contains a value) + +Each field is formatted as a bold markdown label followed by the value, with fields separated by newlines. + +### MITRE Reference / CWE Extraction + +The parser reads the "MITRE reference" column (lines 79-87) and applies conditional mapping: + +- If the value matches the pattern `CWE-NNN` (e.g., "CWE-284"), the integer portion is extracted and set on the finding's `cwe` field. +- If the value is present but does not match the CWE pattern (e.g., "T1059" for a MITRE ATT&CK technique), the full value is stored in the finding's `references` field. +- If the column is empty, neither field is set. + +### Mitigation Construction + +The mitigation field is populated directly from the "Risk Response" column (line 94), which contains the IriusRisk mitigation status in the format: "Planned mitigation: X%. Mitigated: Y%. Unmitigated: Z%." This preserves the original IriusRisk mitigation tracking percentages. + +### Active/Inactive Logic + +Findings are set to active by default (line 96). When the "Current Risk" value is "Very low", the finding is set to inactive, as this indicates the threat has been fully mitigated through implemented countermeasures. + +### Deduplication + +The parser generates a `unique_id_from_tool` by computing a SHA-256 hash of the Component, Threat, and Risk Response fields concatenated with pipe delimiters (lines 74-77). This ensures that each distinct combination of component, threat, and mitigation state produces a unique identifier. On reimport, findings with matching unique IDs are recognized as the same finding rather than being duplicated. + +### Duplicate Rows in Source Data + +IriusRisk CSV exports can contain multiple rows with the same Component and Threat but different Risk Response values. These represent distinct countermeasure paths for the same threat. Each row is imported as a separate finding, distinguished by its unique ID which incorporates the Risk Response field. diff --git a/dojo/tools/iriusrisk/__init__.py b/dojo/tools/iriusrisk/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/dojo/tools/iriusrisk/__init__.py @@ -0,0 +1 @@ + diff --git a/dojo/tools/iriusrisk/parser.py b/dojo/tools/iriusrisk/parser.py new file mode 100644 index 00000000000..70f61250566 --- /dev/null +++ b/dojo/tools/iriusrisk/parser.py @@ -0,0 +1,106 @@ +import csv +import hashlib +import io +import re + +from dojo.models import Finding + +SEVERITY_MAPPING = { + "Very low": "Info", + "Low": "Low", + "Medium": "Medium", + "High": "High", + "Critical": "Critical", +} + + +class IriusriskParser: + + def get_scan_types(self): + return ["IriusRisk Threats Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import IriusRisk threat model CSV exports." + + def get_findings(self, filename, test): + content = filename.read() + if isinstance(content, bytes): + content = content.decode("utf-8") + reader = csv.DictReader(io.StringIO(content), delimiter=",", quotechar='"') + findings = [] + for row in reader: + component = (row.get("Component") or "").strip() + use_case = (row.get("Use case") or "").strip() + source = (row.get("Source") or "").strip() + threat = (row.get("Threat") or "").strip() + risk_response = (row.get("Risk Response") or "").strip() + inherent_risk = (row.get("Inherent Risk") or "").strip() + current_risk = (row.get("Current Risk") or "").strip() + countermeasure_progress = (row.get("Countermeasure progress") or "").strip() + weakness_tests = (row.get("Weakness tests") or "").strip() + countermeasure_tests = (row.get("Countermeasure tests") or "").strip() + projected_risk = (row.get("Projected Risk") or "").strip() + owner = (row.get("Owner") or "").strip() + mitre_reference = (row.get("MITRE reference") or "").strip() + stride_lm = (row.get("STRIDE-LM") or "").strip() + + # Title: truncate to 150 chars with ellipsis if needed + title = threat[:147] + "..." if len(threat) > 150 else threat + + severity = SEVERITY_MAPPING.get(current_risk, "Info") + + # Build description with all available fields + description_parts = [ + f"**Threat:** {threat}", + f"**Component:** {component}", + f"**Use Case:** {use_case}", + f"**Source:** {source}", + f"**Inherent Risk:** {inherent_risk}", + f"**Current Risk:** {current_risk}", + f"**Projected Risk:** {projected_risk}", + f"**Countermeasure Progress:** {countermeasure_progress}", + f"**Weakness Tests:** {weakness_tests}", + f"**Countermeasure Tests:** {countermeasure_tests}", + ] + if owner: + description_parts.append(f"**Owner:** {owner}") + if stride_lm: + description_parts.append(f"**STRIDE-LM:** {stride_lm}") + description = "\n".join(description_parts) + + # Unique ID for deduplication across reimports + unique_id = hashlib.sha256( + f"{component}|{threat}|{risk_response}".encode(), + ).hexdigest() + + # Extract CWE from MITRE reference if present + cwe = None + references = "" + if mitre_reference: + cwe_match = re.match(r"CWE-(\d+)", mitre_reference) + if cwe_match: + cwe = int(cwe_match.group(1)) + else: + references = mitre_reference + + finding = Finding( + test=test, + title=title, + severity=severity, + description=description, + mitigation=risk_response, + component_name=component, + active=current_risk != "Very low", + static_finding=False, + dynamic_finding=False, + unique_id_from_tool=unique_id, + ) + if cwe: + finding.cwe = cwe + if references: + finding.references = references + findings.append(finding) + return findings diff --git a/unittests/scans/iriusrisk/many_vulns.csv b/unittests/scans/iriusrisk/many_vulns.csv new file mode 100644 index 00000000000..529fabc064e --- /dev/null +++ b/unittests/scans/iriusrisk/many_vulns.csv @@ -0,0 +1,7 @@ +"Component","Use case","Source","Threat","Risk Response","Inherent Risk","Current Risk","Countermeasure progress","Weakness tests","Countermeasure tests","Projected Risk","Owner","MITRE reference","STRIDE-LM" +"Router","Elevation of Privilege","Created by Rules Engine","Accessing functionality not properly constrained by ACLs","Planned mitigation: 0%. Mitigated: 0%. Unmitigated: 100%.","High","High","0%","Not tested","Not tested","High",,"CWE-284","Elevation of Privilege" +"API UX Authorization Management","Read or Post data","Created by Rules Engine","An adversary attempts to exploit an application by injecting additional, malicious content during its processing","Planned mitigation: 100%. Mitigated: 0%. Unmitigated: 0%.","Medium","Medium","0%","Not tested","Not tested","Very low",,"T1059", +"API BS Connection Interface Reporting","Read or Post data","Created by Rules Engine","An attacker crafts malicious web links and distributes them hoping to induce users to click on the link","Planned mitigation: 34%. Mitigated: 66%. Unmitigated: 0%.","High","Low","66%","Not tested","Not tested","Very low",,, +"app-srec-audit-events","Networking","Created by Rules Engine","Access to network traffic from other containers creates the potential for various types of attacks such as denial of service or spoofing attack","Planned mitigation: 0%. Mitigated: 100%. Unmitigated: 0%.","High","Very low","100%","Not tested","Not tested","Very low",,, +"API BS Service Provider","General","Created by Rules Engine","An attacker injects, manipulates or forges malicious log entries in the log file, allowing her to mislead a log audit, cover traces of attack, or perform other malicious actions","Planned mitigation: 100%. Mitigated: 0%. Unmitigated: 0%.","Medium","Medium","0%","Not tested","Not tested","Very low","John Smith",, +"Database Server","Data Storage","Created by Rules Engine","An attacker targets the database server to exfiltrate sensitive records","Planned mitigation: 0%. Mitigated: 0%. Unmitigated: 100%.","Critical","Critical","0%","Not tested","Not tested","Critical",,, diff --git a/unittests/scans/iriusrisk/no_vuln.csv b/unittests/scans/iriusrisk/no_vuln.csv new file mode 100644 index 00000000000..1e1565e6230 --- /dev/null +++ b/unittests/scans/iriusrisk/no_vuln.csv @@ -0,0 +1 @@ +"Component","Use case","Source","Threat","Risk Response","Inherent Risk","Current Risk","Countermeasure progress","Weakness tests","Countermeasure tests","Projected Risk","Owner","MITRE reference","STRIDE-LM" diff --git a/unittests/scans/iriusrisk/one_vuln.csv b/unittests/scans/iriusrisk/one_vuln.csv new file mode 100644 index 00000000000..39f6790681a --- /dev/null +++ b/unittests/scans/iriusrisk/one_vuln.csv @@ -0,0 +1,2 @@ +"Component","Use case","Source","Threat","Risk Response","Inherent Risk","Current Risk","Countermeasure progress","Weakness tests","Countermeasure tests","Projected Risk","Owner","MITRE reference","STRIDE-LM" +"Router","Elevation of Privilege","Created by Rules Engine","Accessing functionality not properly constrained by ACLs","Planned mitigation: 0%. Mitigated: 0%. Unmitigated: 100%.","High","High","0%","Not tested","Not tested","High",,, diff --git a/unittests/tools/test_iriusrisk_parser.py b/unittests/tools/test_iriusrisk_parser.py new file mode 100644 index 00000000000..6ef836dea66 --- /dev/null +++ b/unittests/tools/test_iriusrisk_parser.py @@ -0,0 +1,162 @@ +from dojo.models import Test +from dojo.tools.iriusrisk.parser import IriusriskParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestIriusriskParser(DojoTestCase): + + def test_parse_no_findings(self): + with (get_unit_tests_scans_path("iriusrisk") / "no_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(0, len(findings)) + + def test_parse_one_finding(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + + def test_parse_many_findings(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(6, len(findings)) + + def test_finding_severity_high(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual("High", findings[0].severity) + + def test_finding_severity_medium(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual("Medium", findings[1].severity) + + def test_finding_severity_low(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual("Low", findings[2].severity) + + def test_finding_severity_very_low_maps_to_info(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual("Info", findings[3].severity) + + def test_finding_severity_critical(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + # Row 6 (index 5) has Current Risk = "Critical" + self.assertEqual("Critical", findings[5].severity) + + def test_finding_title_truncated_at_150_chars(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertLessEqual(len(findings[4].title), 150) + self.assertTrue(findings[4].title.endswith("...")) + + def test_finding_title_not_truncated_when_short(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual("Accessing functionality not properly constrained by ACLs", findings[0].title) + + def test_finding_component_name(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual("Router", findings[0].component_name) + + def test_finding_description_contains_all_fields(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + desc = findings[0].description + self.assertIn("Accessing functionality not properly constrained by ACLs", desc) + self.assertIn("Router", desc) + self.assertIn("Elevation of Privilege", desc) + self.assertIn("Created by Rules Engine", desc) + self.assertIn("High", desc) + + def test_finding_mitigation(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual( + "Planned mitigation: 0%. Mitigated: 0%. Unmitigated: 100%.", + findings[0].mitigation, + ) + + def test_finding_active_when_risk_not_very_low(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertTrue(findings[0].active) + + def test_finding_inactive_when_very_low(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertFalse(findings[3].active) + + def test_finding_static_finding(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertFalse(findings[0].static_finding) + self.assertFalse(findings[0].dynamic_finding) + + def test_finding_unique_id_from_tool(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertIsNotNone(findings[0].unique_id_from_tool) + self.assertGreater(len(findings[0].unique_id_from_tool), 0) + + def test_finding_unique_id_is_consistent(self): + """Parsing the same file twice should produce the same unique IDs.""" + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + findings1 = IriusriskParser().get_findings(testfile, Test()) + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + findings2 = IriusriskParser().get_findings(testfile, Test()) + self.assertEqual(findings1[0].unique_id_from_tool, findings2[0].unique_id_from_tool) + + def test_finding_with_owner(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertIn("John Smith", findings[4].description) + + def test_finding_with_empty_owner(self): + with (get_unit_tests_scans_path("iriusrisk") / "one_vuln.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + self.assertNotIn("None", findings[0].description) + + def test_finding_cwe_from_mitre_reference(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + # Row 1 (index 0) has MITRE reference = "CWE-284" + self.assertEqual(284, findings[0].cwe) + + def test_finding_references_from_mitre_reference(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + # Row 2 (index 1) has MITRE reference = "T1059" (not a CWE) + self.assertEqual("T1059", findings[1].references) + + def test_finding_stride_lm_in_description(self): + with (get_unit_tests_scans_path("iriusrisk") / "many_vulns.csv").open(encoding="utf-8") as testfile: + parser = IriusriskParser() + findings = parser.get_findings(testfile, Test()) + # Row 1 (index 0) has STRIDE-LM = "Elevation of Privilege" + self.assertIn("STRIDE-LM", findings[0].description) + self.assertIn("Elevation of Privilege", findings[0].description)