diff --git a/docs/content/supported_tools/parsers/file/strix.md b/docs/content/supported_tools/parsers/file/strix.md new file mode 100644 index 00000000000..2b38495a22e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/strix.md @@ -0,0 +1,107 @@ +--- +title: "Strix Scan" +toc_hide: true +--- + +The [Strix](https://github.com/usestrix/strix) parser for DefectDojo supports imports from the JSON report of a Strix security run. This document details how Strix findings are mapped into DefectDojo Findings, which fields are parsed, and the deduplication behavior. + +## Supported File Types + +The Strix parser accepts JSON files in the format of the `vulnerabilities.json` report produced by a Strix run. + +To import Strix results into DefectDojo: + +1. Run Strix against the target codebase +2. Export or copy the resulting `vulnerabilities.json` report +3. Upload the file to DefectDojo using the "Strix Scan" scan type + +The report is a JSON array with one object per finding. A wrapped shape (`{"vulnerabilities": [...]}`) is also accepted for forward compatibility. Every field is optional: the set of keys varies by `finding_class` (code findings carry PoC and CVSS data, dependency findings carry package metadata), so missing fields are left unset on the Finding rather than filled with placeholders. A report that is not a JSON array (or a wrapped one) is rejected with an error instead of silently importing zero findings; a legitimately empty array imports zero findings. + +## Default Deduplication Hashcode Fields + +Strix findings deduplicate using the [unique id from tool algorithm](/triage_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool (populated verbatim from the report's `id` field) + +The Strix `id` is unique per finding and stable across scans of the same codebase, so it is used directly rather than a hash of finding fields. No hashcode fields are registered for this scan type. + +### Sample Scan Data + +Sample Strix scans can be found in the [sample scan data folder](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/strix). + +## Link To Tool + +- [Strix](https://github.com/usestrix/strix) + +## JSON Format + +### Total Fields in JSON + +- Total data fields: 30 +- Total data fields parsed into dedicated Finding fields, the structured description, or the mitigation: 28 +- Remaining fields (`agent_id`, `agent_name`) identify the Strix agent that produced the finding and are not mapped + +### JSON Format Field Mapping Details + +
+Click to expand Field Mapping Table + +| Source Field | DefectDojo Field | Notes | +| ---------------------- | ------------------------- | ------------------------------------------------------------------------ | +| title | title | Finding title | +| severity | severity | Mapped to DefectDojo severity levels; defaults to Info if unrecognized | +| description | description | First section of the structured description | +| impact | impact | Finding impact | +| target | description | Included as a "**Target:**" line in the description | +| confidence | description | Included as a "**Confidence:**" line in the description | +| cvss | cvssv3_score | Numeric CVSS score, set when the value is a number | +| cvss_breakdown | cvssv3 vector | Assembled into a CVSS:3.1 vector string shown next to the score | +| cwe | cwe | `CWE-` extracted from the string | +| cve | unsaved_vulnerability_ids | Set as the finding's vulnerability reference | +| id | vuln_id_from_tool | Strix finding identifier, used verbatim; drives deduplication | +| timestamp | date | Parsed finding date | +| finding_class | static_finding / dynamic_finding | `dynamic` marks a dynamic finding; anything else marks a static finding | +| remediation_steps | mitigation | First part of the mitigation | +| fix_effort | mitigation | Included as a "**Fix effort:**" line in the mitigation | +| fix_pr_body | fix_available | Contributes (with remediation_steps) to a non-null mitigation and fix_available | +| poc_description | steps_to_reproduce | Reproduction steps | +| poc_script_code | steps_to_reproduce | PoC script, appended after the steps | +| technical_analysis | description | "## Technical analysis" description section | +| evidence | description | "## Evidence" description section | +| assumptions | description | "## Assumptions" description section | +| counterevidence | description | "## Counter-evidence" description section | +| severity_change_conditions | description | "## Conditions that would change severity" description section | +| fix_verification | not mapped | Verification note from the fix run; not currently mapped | +| code_locations | file_path / line | First code location anchors the finding's file and start line | +| dependency_metadata.package_name | component_name | Dependency finding component name | +| dependency_metadata.installed_version | component_version | Dependency finding component version | +| dependency_metadata (other keys) | not mapped | Advisory CVSS, ecosystem, manifest path, and reachability metadata are not currently mapped | +| agent_id / agent_name | not mapped | Strix agent identifiers | + +
+ +### Additional Finding Field Settings (JSON Format) + +| Finding Field | Default Value | Notes | +| --------------- | ------------- | -------------------------------------------------------------- | +| active | True | Standard default for imported findings | +| verified | True | Standard default for imported findings | +| static_finding | True | Unless `finding_class` is `dynamic` | +| dynamic_finding | True | Only when `finding_class` is `dynamic` | +| fix_available | True | When the report carries remediation_steps or a fix PR body | + +## Special Processing Notes + +### Severity Mapping + +- `critical` → Critical +- `high` → High +- `medium` → Medium +- `low` → Low +- `info` / `informational` → Info + +Any unrecognized or missing value defaults to Info. + +### Description Construction + +The description is assembled from the parts the report actually carries: the base description, then optional "**Target:**", "**Confidence:**", and "**CVSS:**" lines, followed by the analysis sections ("Technical analysis", "Evidence", "Assumptions", "Counter-evidence", "Conditions that would change severity") rendered as markdown headings. Sections without data are omitted, so a minimal finding gets a short description and a full report gets the complete analysis. diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index b59514d3658..bbda9d81e05 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1953,6 +1953,8 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # that key rewrites itself as time passes even though the report never changed. "Xeol Parser": DEDUPE_ALGO_HASH_CODE, "OPF Scan": DEDUPE_ALGO_HASH_CODE, + # Strix reports a stable per-finding id (verbatim into vuln_id_from_tool), so dedupe on it directly. + "Strix Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL, } # Override the hardcoded settings here via the env var diff --git a/dojo/tools/strix/__init__.py b/dojo/tools/strix/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/dojo/tools/strix/__init__.py @@ -0,0 +1 @@ + diff --git a/dojo/tools/strix/parser.py b/dojo/tools/strix/parser.py new file mode 100644 index 00000000000..6dd94810123 --- /dev/null +++ b/dojo/tools/strix/parser.py @@ -0,0 +1,154 @@ +import json +import re + +from dateutil import parser + +from dojo.models import Finding + + +class StrixParser: + + """ + Parser for the vulnerabilities.json report of a Strix security run. + + Strix reports one entry per finding, and the set of keys varies by + finding_class (code findings carry PoC and CVSS breakdowns, dependency + findings carry package metadata), so every field is optional. + """ + + SEVERITIES = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "informational": "Info", + } + + CWE_PATTERN = re.compile(r"CWE-(\d+)") + + CVSS_METRICS = ( + ("attack_vector", "AV"), + ("attack_complexity", "AC"), + ("privileges_required", "PR"), + ("user_interaction", "UI"), + ("scope", "S"), + ("confidentiality", "C"), + ("integrity", "I"), + ("availability", "A"), + ) + + DESCRIPTION_SECTIONS = ( + ("Technical analysis", "technical_analysis"), + ("Evidence", "evidence"), + ("Assumptions", "assumptions"), + ("Counter-evidence", "counterevidence"), + ("Conditions that would change severity", "severity_change_conditions"), + ) + + def get_scan_types(self): + return ["Strix Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import findings from the vulnerabilities.json report of a Strix security run." + + def get_findings(self, file, test): + data = json.load(file) + # The report is a bare array; the wrapped shape is accepted for + # forward compatibility in case Strix adds report-level metadata. + if isinstance(data, dict): + data = data.get("vulnerabilities") + if not isinstance(data, list): + msg = f"Strix reports are a JSON array; got a {type(data).__name__}." + raise TypeError(msg) + return [self._to_finding(item, test) for item in data if item] + + def _to_finding(self, item, test): + dependency = item.get("dependency_metadata") or {} + code_location = (item.get("code_locations") or [{}])[0] or {} + + finding = Finding( + test=test, + title=item.get("title"), + severity=self._severity(item.get("severity")), + description=self._description(item), + impact=item.get("impact"), + steps_to_reproduce=self._steps_to_reproduce(item), + mitigation=self._mitigation(item), + cwe=self._cwe(item.get("cwe")), + vuln_id_from_tool=item.get("id"), + date=self._date(item.get("timestamp")), + component_name=dependency.get("package_name"), + component_version=dependency.get("installed_version"), + file_path=code_location.get("file"), + line=code_location.get("start_line"), + static_finding=item.get("finding_class") != "dynamic", + dynamic_finding=item.get("finding_class") == "dynamic", + fix_available=bool(item.get("remediation_steps") or item.get("fix_pr_body")), + ) + if item.get("cve"): + finding.unsaved_vulnerability_ids = [item["cve"]] + cvss = item.get("cvss") + if isinstance(cvss, int | float): + finding.cvssv3_score = cvss + return finding + + def _severity(self, value): + return self.SEVERITIES.get(str(value).lower(), "Info") + + def _cwe(self, value): + if not value: + return None + match = self.CWE_PATTERN.search(str(value)) + return int(match.group(1)) if match else None + + def _date(self, timestamp): + if not timestamp: + return None + return parser.parse(timestamp) + + def _cvss_vector(self, breakdown): + if not isinstance(breakdown, dict) or not breakdown: + return None + metrics = [] + for key, abbrev in self.CVSS_METRICS: + value = breakdown.get(key) + if value is None: + return None + metrics.append(f"{abbrev}:{value}") + return "CVSS:3.1/" + "/".join(metrics) + + def _description(self, item): + parts = [] + if item.get("description"): + parts.append(item["description"]) + if item.get("target"): + parts.append(f"**Target:** {item['target']}") + if item.get("confidence"): + parts.append(f"**Confidence:** {item['confidence']}") + cvss = item.get("cvss") + if cvss is not None: + vector = self._cvss_vector(item.get("cvss_breakdown")) + parts.append(f"**CVSS:** {cvss} ({vector})" if vector else f"**CVSS:** {cvss}") + for heading, key in self.DESCRIPTION_SECTIONS: + if item.get(key): + parts.append(f"## {heading}\n{item[key]}") + return "\n\n".join(parts) + + @staticmethod + def _steps_to_reproduce(item): + # poc_script_code arrives already wrapped in a fenced code block + parts = [value for value in (item.get("poc_description"), item.get("poc_script_code")) if value] + return "\n\n".join(parts) or None + + @staticmethod + def _mitigation(item): + parts = [] + if item.get("remediation_steps"): + parts.append(item["remediation_steps"]) + if item.get("fix_effort"): + parts.append(f"**Fix effort:** {item['fix_effort']}") + return "\n\n".join(parts) or None diff --git a/unittests/scans/strix/strix_many_vulns.json b/unittests/scans/strix/strix_many_vulns.json new file mode 100644 index 00000000000..4a4def82a9d --- /dev/null +++ b/unittests/scans/strix/strix_many_vulns.json @@ -0,0 +1,91 @@ +[ + { + "id": "vuln-0002", + "title": "CVE-2026-48526 in pyjwt 2.12.1 (sample_service)", + "severity": "info", + "timestamp": "2026-09-23 07:22:42 UTC", + "description": "The sample service pins `pyjwt==2.12.1` as a direct dependency in `sample_service/uv.lock`, a version covered by CVE-2026-48526: PyJWT does not validate the use of JSON Web Keys with the HMAC algorithm, allowing an attacker to forge valid tokens. Fixed in 2.13.0.", + "impact": "In this codebase no runtime code path loads the installed pyjwt package, so the demonstrated in-context impact is nil; the finding is reported so the vulnerable pin is remediated.", + "target": "/workspace/sample-app (sample_service)", + "remediation_steps": "Bump the direct dependency from `pyjwt>=2.4.0` to `pyjwt>=2.13.0` and regenerate `uv.lock`.", + "evidence": "Advisory CVE-2026-48526 applies to pyjwt at installed version 2.12.1 and is fixed in 2.13.0. Dependency chain: sample-service@0.1.0 > pyjwt@2.12.1 (direct).", + "assumptions": "Analysis is static; the service was not executed in the sandbox.", + "fix_effort": "trivial", + "cvss": 0.0, + "cve": "CVE-2026-48526", + "cwe": "CWE-347", + "finding_class": "dependency_cve", + "dependency_metadata": { + "package_name": "pyjwt", + "installed_version": "2.12.1", + "advisory_cvss": 7.4, + "package_ecosystem": "pypi", + "manifest_path": "sample_service/pyproject.toml", + "fixed_version": "2.13.0", + "dependency_path": "sample-service@0.1.0 > pyjwt@2.12.1 (direct)", + "reachability": "not_imported_by_application_code", + "reachability_evidence": "No `import jwt` references exist under sample_service/." + }, + "agent_id": "a1b2c3d4", + "agent_name": "Dependency Reviewer" + }, + { + "id": "vuln-0017", + "title": "Insecure session token generated with os.urandom truncated to 8 bytes", + "severity": "high", + "timestamp": "2026-09-23 08:41:12 UTC", + "description": "The session token is generated by truncating `os.urandom` output to 8 bytes, leaving only 64 bits of entropy before it is hashed.", + "impact": "An attacker with a valid session id can attempt an offline brute force of the remaining token space for accounts whose sessions outlive the short rotation window.", + "target": "/workspace/sample-app", + "technical_analysis": "`server/services/sessions.py` calls `os.urandom(32)[:8]` and passes the result through `hashlib.sha256` before storing it. The truncation happens before hashing, so the stored value carries at most 64 bits of entropy.", + "poc_description": "1. Log in and capture the session cookie.\n2. Replay the cookie from a second client to confirm no server-side binding.\n3. Time an offline search over the 64-bit space on commodity GPU hardware.", + "poc_script_code": "```python\nimport os\n\ntoken = os.urandom(32)[:8]\nprint(f\"session token entropy: {len(token) * 8} bits\")\n```", + "remediation_steps": "Remove the `[:8]` truncation and use the full `os.urandom(32)` value.", + "evidence": "From `server/services/sessions.py` line 42: `token = os.urandom(32)[:8]`.", + "confidence": "high", + "fix_effort": "trivial", + "cvss": 7.5, + "cvss_breakdown": { + "attack_vector": "N", + "attack_complexity": "H", + "privileges_required": "N", + "user_interaction": "N", + "scope": "U", + "confidentiality": "H", + "integrity": "N", + "availability": "N" + }, + "cwe": "CWE-331", + "finding_class": "static", + "fix_verification": "The patch removes the truncation; `python3 -m py_compile server/services/sessions.py` passes.", + "fix_pr_body": "Remove the 8 byte truncation of session tokens so the full 256 bits of entropy are retained.", + "code_locations": [ + { + "file": "server/services/sessions.py", + "start_line": 42, + "end_line": 44, + "snippet": "token = os.urandom(32)[:8]", + "label": "token generation", + "fix_before": "token = os.urandom(32)[:8]", + "fix_after": "token = os.urandom(32)" + } + ], + "agent_id": "e5f6a7b8", + "agent_name": "Auth Reviewer" + }, + { + "id": "vuln-0020", + "title": "Verbose error responses leak internal stack traces to end users", + "severity": "low", + "timestamp": "2026-09-23 09:02:10 UTC", + "description": "Unhandled exceptions in the API server return the raw Python traceback to the client when debug mode is left enabled in the default configuration template.", + "impact": "Stack traces disclose internal module paths, dependency versions, and sometimes user data from the failing frame.", + "target": "/workspace/sample-app", + "remediation_steps": "Disable debug mode in the default template and return a generic error body to clients.", + "assumptions": "Deployment configurations that already override the debug flag are not affected.", + "fix_effort": "trivial", + "finding_class": "static", + "agent_id": "e5f6a7b8", + "agent_name": "Auth Reviewer" + } +] diff --git a/unittests/scans/strix/strix_no_vuln.json b/unittests/scans/strix/strix_no_vuln.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/unittests/scans/strix/strix_no_vuln.json @@ -0,0 +1 @@ +[] diff --git a/unittests/scans/strix/strix_one_vuln.json b/unittests/scans/strix/strix_one_vuln.json new file mode 100644 index 00000000000..168ba49640a --- /dev/null +++ b/unittests/scans/strix/strix_one_vuln.json @@ -0,0 +1,36 @@ +[ + { + "id": "vuln-0001", + "title": "Legacy jobs.list handler bypasses task and queue permission filtering", + "severity": "medium", + "timestamp": "2026-09-23 07:13:47 UTC", + "description": "The legacy handler for `jobs.list` (registered at `min_version` \"1.0\", served for API versions below 2.1) returns raw job entries without the task and queue permission filtering that the 2.1+ handler applies.", + "impact": "An authenticated user can, within its own organization, learn the id and name of every task currently being executed by any organization job runner, including tasks in projects the caller cannot read.", + "target": "/workspace/sample-app", + "technical_analysis": "`server/services/jobs.py` registers two handlers for `jobs.list`. The legacy one returns `job_bll.get_all(org_id, ...)` entries directly, while `list_21` applies a `PermissionsFilter` for both `Task` and `Queue` references. `_get_endpoint` selects the handler whose `min_version` is the highest one not exceeding the requested version, so an authenticated user bypasses both filters by pinning an old API version in the URL.", + "poc_description": "1. As a `user`-role member of organization A, have another user start a task in a project the attacker has no read access to, executed by an organization job runner.\n2. Call `POST /v2.0/jobs.list` (explicitly requesting API version 2.0, which resolves to the legacy handler).\n3. The response lists every organization job runner with `task: {id, name}` of the restricted task, `queue`, and `queues` ids.", + "poc_script_code": "```python\n#!/usr/bin/env python3\nimport json\nimport requests\n\nBASE = \"http://\"\nCREDS = (\"\", \"\")\n\nresp = requests.post(f\"{BASE}/v2.0/jobs.list\", auth=CREDS, json={\"last_seen\": 3600}, timeout=30)\nresp.raise_for_status()\nlegacy = resp.json()[\"jobs\"]\nprint(json.dumps(legacy, indent=2))\n```", + "remediation_steps": "Apply the same `PermissionsFilter`-based redaction of task and queue references in the legacy `jobs.list` handler that the 2.1 handler already performs.", + "evidence": "Handler pair in `server/services/jobs.py`:\n\n```python\n@endpoint(\n \"jobs.list\",\n request_data_model=ListRequest,\n response_data_model=ListResponse_v1_5,\n)\ndef list_all(call: APICall, org_id, request: ListRequest):\n jobs = job_bll.get_all(org_id, request.last_seen)\n```", + "assumptions": "Assumes an authenticated `user`-role account in the same organization as the victim job runners/tasks.", + "counterevidence": "The leaked references do not by themselves unlock writes: `queues.enqueue`/`queues.update` re-validate via `QueueAccessValidator` and `ensure_permissions`, so the impact stays informational.", + "confidence": "medium", + "severity_change_conditions": "Evidence that API versions below 2.1 are rejected by deployment configuration would lower the severity further.", + "fix_effort": "low", + "cvss": 4.3, + "cvss_breakdown": { + "attack_vector": "N", + "attack_complexity": "L", + "privileges_required": "L", + "user_interaction": "N", + "scope": "U", + "confidentiality": "L", + "integrity": "N", + "availability": "N" + }, + "cwe": "CWE-862", + "finding_class": "dynamic", + "agent_id": "c0fc9dfe", + "agent_name": "Platform Reviewer" + } +] diff --git a/unittests/tools/test_strix_parser.py b/unittests/tools/test_strix_parser.py new file mode 100644 index 00000000000..c7b804eaaa2 --- /dev/null +++ b/unittests/tools/test_strix_parser.py @@ -0,0 +1,171 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.strix.parser import StrixParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestStrixParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("strix") / filename).open(encoding="utf-8") as file: + return list(StrixParser().get_findings(file, Test())) + + def test_scan_type_metadata(self): + parser = StrixParser() + self.assertEqual(["Strix Scan"], parser.get_scan_types()) + self.assertEqual("Strix Scan", parser.get_label_for_scan_types("Strix Scan")) + self.assertIn("vulnerabilities.json", parser.get_description_for_scan_types("Strix Scan")) + + def test_no_vuln(self): + self.assertEqual([], self.parse("strix_no_vuln.json")) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("strix_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, from a representative Strix run report.""" + finding = self.parse("strix_one_vuln.json")[0] + + self.assertEqual("Legacy jobs.list handler bypasses task and queue permission filtering", finding.title) + self.assertEqual("vuln-0001", finding.vuln_id_from_tool) + self.assertEqual("Medium", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual(862, finding.cwe) + self.assertTrue(finding.dynamic_finding) + self.assertFalse(finding.static_finding) + self.assertEqual( + datetime(2026, 9, 23, 7, 13, 47, tzinfo=UTC), + finding.date, + ) + self.assertEqual(4.3, finding.cvssv3_score) + self.assertTrue(finding.fix_available) + + self.assertIn("**Target:** /workspace/sample-app", finding.description) + self.assertIn("**Confidence:** medium", finding.description) + self.assertIn("**CVSS:** 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N)", finding.description) + self.assertIn("## Technical analysis", finding.description) + self.assertIn("## Evidence", finding.description) + self.assertIn("## Assumptions", finding.description) + self.assertIn("## Counter-evidence", finding.description) + self.assertIn("## Conditions that would change severity", finding.description) + + self.assertIn("Call `POST /v2.0/jobs.list`", finding.steps_to_reproduce) + self.assertIn("```python", finding.steps_to_reproduce) + self.assertIn("jobs.list", finding.steps_to_reproduce) + + self.assertIn("`PermissionsFilter`-based redaction", finding.mitigation) + self.assertIn("**Fix effort:** low", finding.mitigation) + + self.assertIn("learn the id and name", finding.impact) + + def test_many_vuln(self): + findings = self.parse("strix_many_vulns.json") + self.assertEqual(3, len(findings)) + for finding in findings: + self.assertIn(finding.severity, Finding.SEVERITIES) + + def test_dependency_finding_mapping(self): + """dependency_cve findings carry package metadata and a CVE identifier.""" + finding = next(f for f in self.parse("strix_many_vulns.json") if f.vuln_id_from_tool == "vuln-0002") + + self.assertEqual("Info", finding.severity) + self.assertEqual(347, finding.cwe) + self.assertEqual(["CVE-2026-48526"], finding.unsaved_vulnerability_ids) + self.assertEqual("pyjwt", finding.component_name) + self.assertEqual("2.12.1", finding.component_version) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertIn("**CVSS:** 0.0", finding.description) + + def test_code_location_mapping(self): + """code_locations anchor the finding to a file and line.""" + finding = next(f for f in self.parse("strix_many_vulns.json") if f.vuln_id_from_tool == "vuln-0017") + + self.assertEqual("server/services/sessions.py", finding.file_path) + self.assertEqual(42, finding.line) + self.assertEqual(331, finding.cwe) + self.assertEqual(7.5, finding.cvssv3_score) + + def test_minimal_finding_uses_defaults(self): + """Fields are optional per finding class; missing ones stay empty.""" + finding = next(f for f in self.parse("strix_many_vulns.json") if f.vuln_id_from_tool == "vuln-0020") + + self.assertEqual("Low", finding.severity) + self.assertIsNone(finding.cwe) + self.assertIsNone(finding.file_path) + self.assertIsNone(finding.steps_to_reproduce) + self.assertIsNone(finding.cvssv3_score) + self.assertTrue(finding.fix_available) + self.assertNotIn("## Evidence", finding.description) + self.assertNotIn("**Confidence:**", finding.description) + self.assertNotIn("**CVSS:**", finding.description) + + def test_severity_map(self): + parser = StrixParser() + for level, expected in [ + ("critical", "Critical"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("info", "Info"), + ]: + self.assertEqual(expected, parser._severity(level)) + + self.assertEqual("Info", parser._severity("severe")) + self.assertEqual("Info", parser._severity(None)) + + def test_cwe_extraction(self): + parser = StrixParser() + self.assertEqual(862, parser._cwe("CWE-862")) + self.assertEqual(79, parser._cwe("CWE-79: Improper Neutralization of Input")) + self.assertIsNone(parser._cwe(None)) + self.assertIsNone(parser._cwe("no identifier here")) + + def test_cvss_vector_from_breakdown(self): + parser = StrixParser() + self.assertIsNone(parser._cvss_vector(None)) + self.assertIsNone(parser._cvss_vector({})) + self.assertIsNone(parser._cvss_vector({"attack_vector": "N"})) + self.assertEqual( + "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", + parser._cvss_vector({ + "attack_vector": "N", + "attack_complexity": "L", + "privileges_required": "N", + "user_interaction": "N", + "scope": "U", + "confidentiality": "H", + "integrity": "N", + "availability": "N", + }), + ) + + def test_wrapped_report_shape_is_accepted(self): + with (get_unit_tests_scans_path("strix") / "strix_one_vuln.json").open(encoding="utf-8") as file: + report = io.StringIO(json.dumps({"vulnerabilities": json.load(file)})) + findings = list(StrixParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("vuln-0001", findings[0].vuln_id_from_tool) + + def test_empty_entries_are_skipped(self): + report = io.StringIO(json.dumps([None, {}])) + self.assertEqual([], list(StrixParser().get_findings(report, Test()))) + + def test_finding_without_remediation_has_no_fix_available(self): + report = io.StringIO(json.dumps([{ + "id": "vuln-0003", + "title": "No fix proposed yet", + "severity": "low", + "description": "Awaiting triage.", + }])) + finding = list(StrixParser().get_findings(report, Test()))[0] + self.assertFalse(finding.fix_available) + self.assertIsNone(finding.mitigation) + + def test_wrong_shape_is_rejected(self): + with self.assertRaises(TypeError): + list(StrixParser().get_findings(io.StringIO('{"run": "x"}'), Test())) + with self.assertRaises(TypeError): + list(StrixParser().get_findings(io.StringIO('"a string"'), Test()))