regscale-cli 6.17.0.0__py3-none-any.whl → 6.19.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of regscale-cli might be problematic. Click here for more details.

Files changed (48) hide show
  1. regscale/__init__.py +1 -1
  2. regscale/core/app/api.py +5 -0
  3. regscale/core/login.py +3 -0
  4. regscale/integrations/api_paginator.py +932 -0
  5. regscale/integrations/api_paginator_example.py +348 -0
  6. regscale/integrations/commercial/__init__.py +11 -10
  7. regscale/integrations/commercial/burp.py +4 -0
  8. regscale/integrations/commercial/{qualys.py → qualys/__init__.py} +756 -105
  9. regscale/integrations/commercial/qualys/scanner.py +1051 -0
  10. regscale/integrations/commercial/qualys/variables.py +21 -0
  11. regscale/integrations/commercial/sicura/api.py +1 -0
  12. regscale/integrations/commercial/stigv2/click_commands.py +36 -8
  13. regscale/integrations/commercial/stigv2/stig_integration.py +63 -9
  14. regscale/integrations/commercial/tenablev2/__init__.py +9 -0
  15. regscale/integrations/commercial/tenablev2/authenticate.py +23 -2
  16. regscale/integrations/commercial/tenablev2/commands.py +779 -0
  17. regscale/integrations/commercial/tenablev2/jsonl_scanner.py +1999 -0
  18. regscale/integrations/commercial/tenablev2/sc_scanner.py +600 -0
  19. regscale/integrations/commercial/tenablev2/scanner.py +7 -5
  20. regscale/integrations/commercial/tenablev2/utils.py +21 -4
  21. regscale/integrations/commercial/tenablev2/variables.py +4 -0
  22. regscale/integrations/jsonl_scanner_integration.py +523 -142
  23. regscale/integrations/scanner_integration.py +102 -26
  24. regscale/integrations/transformer/__init__.py +17 -0
  25. regscale/integrations/transformer/data_transformer.py +445 -0
  26. regscale/integrations/transformer/mappings/__init__.py +8 -0
  27. regscale/integrations/variables.py +2 -0
  28. regscale/models/__init__.py +5 -2
  29. regscale/models/integration_models/cisa_kev_data.json +63 -7
  30. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  31. regscale/models/regscale_models/asset.py +5 -2
  32. regscale/models/regscale_models/file.py +5 -2
  33. regscale/regscale.py +3 -1
  34. {regscale_cli-6.17.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/METADATA +1 -1
  35. {regscale_cli-6.17.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/RECORD +47 -31
  36. tests/regscale/core/test_version.py +22 -0
  37. tests/regscale/integrations/__init__.py +0 -0
  38. tests/regscale/integrations/test_api_paginator.py +597 -0
  39. tests/regscale/integrations/test_integration_mapping.py +60 -0
  40. tests/regscale/integrations/test_issue_creation.py +317 -0
  41. tests/regscale/integrations/test_issue_due_date.py +46 -0
  42. tests/regscale/integrations/transformer/__init__.py +0 -0
  43. tests/regscale/integrations/transformer/test_data_transformer.py +850 -0
  44. regscale/integrations/commercial/tenablev2/click.py +0 -1637
  45. {regscale_cli-6.17.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/LICENSE +0 -0
  46. {regscale_cli-6.17.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/WHEEL +0 -0
  47. {regscale_cli-6.17.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/entry_points.txt +0 -0
  48. {regscale_cli-6.17.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1051 @@
1
+ """
2
+ Qualys Total Cloud scanner integration class using JSONLScannerIntegration.
3
+ """
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ import traceback
9
+ import xml.etree.ElementTree as ET
10
+ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, TextIO
11
+
12
+ from pathlib import Path
13
+ from rich.progress import Progress, TextColumn, BarColumn, SpinnerColumn, TimeElapsedColumn, TaskID
14
+
15
+ from regscale.core.app.utils.app_utils import get_current_datetime
16
+ from regscale.integrations.commercial.qualys.variables import QualysVariables
17
+ from regscale.integrations.jsonl_scanner_integration import JSONLScannerIntegration
18
+ from regscale.integrations.scanner_integration import (
19
+ IntegrationAsset,
20
+ IntegrationFinding,
21
+ issue_due_date,
22
+ ScannerIntegrationType,
23
+ )
24
+ from regscale.integrations.variables import ScannerVariables
25
+ from regscale.models import AssetStatus, IssueSeverity, IssueStatus
26
+
27
+ logger = logging.getLogger("regscale")
28
+
29
+ NO_RESULTS = "No results available"
30
+ NO_DESCRIPTION = "No description available"
31
+ NO_REMEDIATION = "No remediation information available"
32
+
33
+
34
+ class QualysTotalCloudJSONLIntegration(JSONLScannerIntegration):
35
+ """Class for handling Qualys Total Cloud scanner integration using JSONL."""
36
+
37
+ title: str = "Qualys Total Cloud"
38
+ asset_identifier_field: str = "qualysId"
39
+ finding_severity_map: Dict[str, Any] = {
40
+ "0": IssueSeverity.NotAssigned.value,
41
+ "1": IssueSeverity.Low.value,
42
+ "2": IssueSeverity.Moderate.value,
43
+ "3": IssueSeverity.Moderate.value,
44
+ "4": IssueSeverity.High.value,
45
+ "5": IssueSeverity.Critical.value,
46
+ }
47
+
48
+ finding_status_map = {
49
+ "New": IssueStatus.Open,
50
+ "Active": IssueStatus.Open,
51
+ "Fixed": IssueStatus.Closed,
52
+ }
53
+
54
+ # Constants for file paths
55
+ ASSETS_FILE = "./artifacts/qualys_total_cloud_assets.jsonl"
56
+ FINDINGS_FILE = "./artifacts/qualys_total_cloud_findings.jsonl"
57
+
58
+ def __init__(self, *args, **kwargs):
59
+ """
60
+ Initialize the QualysTotalCloudJSONLIntegration object.
61
+
62
+ :param Any *args: Variable positional arguments
63
+ :param Any **kwargs: Variable keyword arguments
64
+ """
65
+ self.type = ScannerIntegrationType.VULNERABILITY
66
+ self.xml_data = kwargs.pop("xml_data", None)
67
+ # Setting a dummy file path to avoid validation errors
68
+ if self.xml_data and "file_path" not in kwargs:
69
+ kwargs["file_path"] = None
70
+
71
+ # Apply ScannerVariables settings
72
+ if not kwargs.get("vulnerability_creation"):
73
+ # Check QualysVariables-specific override first
74
+ if hasattr(QualysVariables, "vulnerabilityCreation") and QualysVariables.vulnerabilityCreation:
75
+ kwargs["vulnerability_creation"] = QualysVariables.vulnerabilityCreation
76
+ logger.info(f"Using Qualys-specific vulnerability creation mode: {kwargs['vulnerability_creation']}")
77
+ # Use global ScannerVariables if no Qualys-specific setting
78
+ elif hasattr(ScannerVariables, "vulnerabilityCreation"):
79
+ kwargs["vulnerability_creation"] = ScannerVariables.vulnerabilityCreation
80
+ logger.info(f"Using global vulnerability creation mode: {kwargs['vulnerability_creation']}")
81
+
82
+ # Apply SSL verification setting from ScannerVariables
83
+ if not kwargs.get("ssl_verify") and hasattr(ScannerVariables, "sslVerify"):
84
+ kwargs["ssl_verify"] = ScannerVariables.sslVerify
85
+ logger.debug(f"Using SSL verification setting: {kwargs['ssl_verify']}")
86
+
87
+ # Apply ScannerVariables.threadMaxWorkers if available
88
+ if not kwargs.get("max_workers") and hasattr(ScannerVariables, "threadMaxWorkers"):
89
+ kwargs["max_workers"] = ScannerVariables.threadMaxWorkers
90
+ logger.debug(f"Using thread max workers: {kwargs['max_workers']}")
91
+
92
+ super().__init__(*args, **kwargs)
93
+ # No need to initialize clients, they are inherited from the parent class
94
+
95
+ def is_valid_file(self, data: Any, file_path: Union[Path, str]) -> Tuple[bool, Optional[Dict[str, Any]]]:
96
+ """
97
+ Check if the XML data is valid for Qualys Total Cloud.
98
+
99
+ :param Any data: XML data to validate
100
+ :param Union[Path, str] file_path: Path to the file (not used in this implementation)
101
+ :return: Tuple of (is_valid, data)
102
+ :rtype: Tuple[bool, Optional[Dict[str, Any]]]
103
+ """
104
+ # This would normally check the file structure, but for XML data we'll assume it's valid
105
+ # if it contains HOST_LIST_VM_DETECTION_OUTPUT
106
+ if not data or not isinstance(data, dict):
107
+ logger.warning("Data is not a dictionary")
108
+ return False, None
109
+
110
+ if "HOST_LIST_VM_DETECTION_OUTPUT" not in data:
111
+ logger.warning("Data does not contain HOST_LIST_VM_DETECTION_OUTPUT")
112
+ return False, None
113
+
114
+ return True, data
115
+
116
+ def find_valid_files(self, path: Union[Path, str]) -> Iterator[Tuple[Union[Path, str], Dict[str, Any]]]:
117
+ """
118
+ Process XML data instead of files on disk.
119
+
120
+ :param Union[Path, str] path: Path (not used in this implementation)
121
+ :return: Iterator yielding tuples of (dummy path, XML data)
122
+ :rtype: Iterator[Tuple[Union[Path, str], Dict[str, Any]]]
123
+ """
124
+ if not self.xml_data:
125
+ logger.error("No XML data provided for Qualys integration")
126
+ return
127
+
128
+ # Use a dummy file path since we're processing XML data directly
129
+ dummy_path = "qualys_xml_data.xml"
130
+ is_valid, validated_data = self.is_valid_file(self.xml_data, dummy_path)
131
+
132
+ if is_valid and validated_data is not None:
133
+ yield dummy_path, validated_data
134
+
135
+ def parse_asset(self, file_path: Union[Path, str] = None, data: Dict[str, Any] = None, host=None):
136
+ """
137
+ Parse a single asset from a Qualys host data.
138
+
139
+ :param Union[Path, str] file_path: Path to the file (included for compatibility)
140
+ :param Dict[str, Any] data: The parsed data (included for compatibility)
141
+ :param host: XML Element or dictionary representing a host
142
+ :return: IntegrationAsset object
143
+ :rtype: IntegrationAsset
144
+ """
145
+ # Handle the case when the file_path contains the host data (common in tests)
146
+ if isinstance(file_path, dict) and not host and not data:
147
+ host = file_path
148
+
149
+ # Use host parameter if provided (for backward compatibility)
150
+ if host is None:
151
+ host = data # In this implementation, we treat data as the host
152
+
153
+ # Handle None input gracefully
154
+ if host is None:
155
+ logger.warning("No host data provided to parse_asset")
156
+ # Generate a placeholder asset with minimal information
157
+ return IntegrationAsset(
158
+ name="Unknown-Qualys-Asset",
159
+ identifier=str(int(time.time())), # Use timestamp as fallback ID
160
+ asset_type="Server",
161
+ asset_category="IT",
162
+ status=AssetStatus.Active,
163
+ parent_id=self.plan_id,
164
+ parent_module="securityplans",
165
+ notes="Generated for missing Qualys data",
166
+ )
167
+
168
+ # Convert XML Element to dict if necessary
169
+ if not isinstance(host, dict) and hasattr(host, "tag"): # It's an XML Element
170
+ host = self._xml_element_to_dict(host)
171
+
172
+ # Process dictionary
173
+ if isinstance(host, dict):
174
+ # Check if we got the full data structure or just a host dictionary
175
+ if "HOST_LIST_VM_DETECTION_OUTPUT" in host:
176
+ # Navigate to the HOST data within the nested structure
177
+ try:
178
+ host = (
179
+ host.get("HOST_LIST_VM_DETECTION_OUTPUT", {})
180
+ .get("RESPONSE", {})
181
+ .get("HOST_LIST", {})
182
+ .get("HOST", {})
183
+ )
184
+ except (AttributeError, KeyError):
185
+ logger.error("Could not navigate to HOST data in dictionary")
186
+ raise ValueError("Invalid host data structure")
187
+
188
+ # Extract host data from dictionary
189
+ host_id = host.get("ID", "")
190
+ ip = host.get("IP", "")
191
+ dns = host.get("DNS", "")
192
+ os = host.get("OS", "")
193
+ last_scan = host.get("LAST_SCAN_DATETIME", "")
194
+ network_id = host.get("NETWORK_ID", "")
195
+
196
+ # Try to get FQDN from DNS_DATA if available
197
+ fqdn = None
198
+ dns_data = host.get("DNS_DATA", {})
199
+ if dns_data:
200
+ fqdn = dns_data.get("FQDN", "")
201
+
202
+ asset = IntegrationAsset(
203
+ name=dns or ip or f"QualysAsset-{host_id}",
204
+ identifier=host_id,
205
+ asset_type="Server",
206
+ asset_category="IT",
207
+ ip_address=ip,
208
+ fqdn=fqdn,
209
+ operating_system=os,
210
+ status=AssetStatus.Active,
211
+ external_id=host_id,
212
+ date_last_updated=last_scan,
213
+ mac_address=None,
214
+ vlan_id=network_id,
215
+ notes=f"Qualys Asset ID: {host_id}",
216
+ parent_id=self.plan_id,
217
+ parent_module="securityplans",
218
+ )
219
+ return asset
220
+
221
+ # If we got here, we don't know how to handle the data
222
+ logger.error(f"Unexpected host data type: {type(host)}")
223
+ raise ValueError(f"Cannot parse asset from data type: {type(host)}")
224
+
225
+ def parse_finding(
226
+ self,
227
+ asset_identifier: str = None,
228
+ data: Dict[str, Any] = None,
229
+ item: Dict[str, Any] = None,
230
+ detection=None,
231
+ host_id=None,
232
+ ):
233
+ """
234
+ Parse a single finding from a Qualys detection.
235
+
236
+ :param str asset_identifier: The identifier of the asset this finding belongs to (for compatibility)
237
+ :param Dict[str, Any] data: The asset data (not used in this implementation, for compatibility)
238
+ :param Dict[str, Any] item: The finding data (for compatibility)
239
+ :param detection: XML Element or dict representing a detection
240
+ :param host_id: Host ID this detection belongs to
241
+ :return: IntegrationFinding object
242
+ :rtype: IntegrationFinding
243
+ """
244
+ # For backward compatibility
245
+ detection_to_use = detection if detection is not None else item
246
+ host_id_to_use = host_id if host_id is not None else asset_identifier
247
+
248
+ # Handle None input gracefully
249
+ if detection_to_use is None:
250
+ logger.warning("No detection data provided to parse_finding")
251
+ # Use host_id or generate a placeholder if none provided
252
+ if not host_id_to_use:
253
+ host_id_to_use = f"unknown-host-{int(time.time())}"
254
+
255
+ # Generate a placeholder finding with minimal information
256
+ return IntegrationFinding(
257
+ title="Unknown Qualys Finding",
258
+ description="No detection data was provided",
259
+ severity=IssueSeverity.Low.value,
260
+ status=IssueStatus.Open,
261
+ plugin_name="QID-unknown",
262
+ plugin_id=self.title,
263
+ asset_identifier=host_id_to_use,
264
+ category="Vulnerability",
265
+ scan_date=self.scan_date or get_current_datetime(),
266
+ external_id=f"unknown-finding-{int(time.time())}",
267
+ )
268
+
269
+ # Convert XML Element to dict if necessary
270
+ if not isinstance(detection_to_use, dict) and hasattr(detection_to_use, "tag"): # It's an XML Element
271
+ detection_to_use = self._xml_element_to_dict(detection_to_use)
272
+
273
+ return self._parse_finding_from_dict(detection_to_use, host_id_to_use)
274
+
275
+ def _parse_finding_from_dict(self, detection, host_id):
276
+ """
277
+ Parse a finding from a dictionary representation.
278
+
279
+ :param detection: Dictionary containing finding data
280
+ :param host_id: Host ID this detection belongs to
281
+ :return: IntegrationFinding object
282
+ """
283
+ # Validate detection is a dictionary
284
+ if not isinstance(detection, dict):
285
+ logger.warning(f"Expected dictionary for detection, got {type(detection)}")
286
+ detection = {} # Use empty dict to prevent further errors
287
+
288
+ # Extract basic finding information
289
+ finding_data = self._extract_basic_finding_data_dict(detection)
290
+
291
+ # Get CVE information
292
+ finding_data["cve_id"] = self._extract_cve_id_from_dict(detection)
293
+
294
+ # Extract issue data
295
+ self._extract_issue_data_from_dict(detection, finding_data)
296
+
297
+ # Build evidence
298
+ finding_data["evidence"] = self._build_evidence(
299
+ finding_data.get("qid", "Unknown"),
300
+ host_id or "unknown-host",
301
+ finding_data.get("first_found", self.scan_date or get_current_datetime()),
302
+ finding_data.get("last_found", self.scan_date or get_current_datetime()),
303
+ finding_data.get("results", NO_RESULTS),
304
+ )
305
+
306
+ # Create the finding object
307
+ finding = self._create_finding_object(finding_data, host_id or "unknown-host")
308
+
309
+ # Normalize dates for JSON serialization
310
+ self._normalize_finding_dates(finding)
311
+
312
+ return finding
313
+
314
+ def _extract_basic_finding_data_dict(self, detection: Dict[str, Any]) -> Dict[str, Any]:
315
+ """
316
+ Extract basic finding information from dictionary data.
317
+
318
+ :param Dict[str, Any] detection: Detection data
319
+ :return: Dictionary with basic finding data
320
+ :rtype: Dict[str, Any]
321
+ """
322
+ if not detection:
323
+ detection = {} # Ensure we have at least an empty dict
324
+
325
+ current_time = self.scan_date or get_current_datetime()
326
+
327
+ return {
328
+ "qid": detection.get("QID", "Unknown"),
329
+ "severity": detection.get("SEVERITY", "0"),
330
+ "status": detection.get("STATUS", "New"),
331
+ "first_found": detection.get("FIRST_FOUND_DATETIME", current_time),
332
+ "last_found": detection.get("LAST_FOUND_DATETIME", current_time),
333
+ "unique_id": detection.get("UNIQUE_VULN_ID", f"QID-{detection.get('QID', 'Unknown')}"),
334
+ "results": detection.get("RESULTS", NO_RESULTS),
335
+ "cvss_v3_score": detection.get("CVSS3_BASE"),
336
+ "cvss_v3_vector": detection.get("CVSS3_VECTOR", ""),
337
+ "cvss_v2_score": detection.get("CVSS_BASE"),
338
+ "cvss_v2_vector": detection.get("CVSS_VECTOR", ""),
339
+ }
340
+
341
+ def _extract_basic_finding_data_xml(self, detection: Optional[Union[Dict[str, Any], ET.Element]]) -> Dict[str, Any]:
342
+ """
343
+ Deprecated: Convert to dict first then use _extract_basic_finding_data_dict.
344
+
345
+ :param Optional[Union[Dict[str, Any], ET.Element]] detection: Detection data as dictionary or XML Element
346
+ :return: Dictionary with basic finding data
347
+ :rtype: Dict[str, Any]
348
+ """
349
+ if detection is None:
350
+ # Return default values if detection is None
351
+ return {
352
+ "qid": "Unknown",
353
+ "severity": "0",
354
+ "status": "New",
355
+ "first_found": self.scan_date,
356
+ "last_found": self.scan_date,
357
+ "unique_id": "Unknown",
358
+ "results": NO_RESULTS,
359
+ "cvss_v3_score": None,
360
+ "cvss_v3_vector": "",
361
+ "cvss_v2_score": None,
362
+ "cvss_v2_vector": "",
363
+ }
364
+
365
+ # Convert XML to dict if needed
366
+ if not isinstance(detection, dict) and hasattr(detection, "tag"):
367
+ detection = self._xml_element_to_dict(detection)
368
+
369
+ # Use dict extraction method
370
+ return self._extract_basic_finding_data_dict(detection)
371
+
372
+ def _extract_cve_id_from_dict(self, detection: Optional[Dict[str, Any]]) -> str:
373
+ """
374
+ Extract CVE ID from dictionary data.
375
+
376
+ :param Optional[Dict[str, Any]] detection: Detection data
377
+ :return: CVE ID string
378
+ :rtype: str
379
+ """
380
+ if not detection:
381
+ return ""
382
+
383
+ cve_id = ""
384
+ try:
385
+ cve_list = detection.get("CVE_ID_LIST", {})
386
+ if not cve_list:
387
+ return ""
388
+
389
+ if not isinstance(cve_list, dict):
390
+ logger.warning(f"Expected dictionary for CVE_ID_LIST, got {type(cve_list)}")
391
+ return ""
392
+
393
+ if "CVE_ID" in cve_list:
394
+ cve_data = cve_list.get("CVE_ID", [])
395
+ if isinstance(cve_data, list) and cve_data:
396
+ cve_id = str(cve_data[0]) if cve_data[0] else ""
397
+ elif isinstance(cve_data, str):
398
+ cve_id = cve_data
399
+ elif cve_data:
400
+ # Try to convert to string if it's something else
401
+ cve_id = str(cve_data)
402
+ except Exception as e:
403
+ logger.warning(f"Error extracting CVE_ID: {str(e)}")
404
+
405
+ return cve_id
406
+
407
+ def _extract_cve_id_from_xml(self, detection: Optional[Union[Dict[str, Any], ET.Element]]) -> str:
408
+ """
409
+ Deprecated: Convert to dict first then use _extract_cve_id_from_dict.
410
+
411
+ :param Optional[Union[Dict[str, Any], ET.Element]] detection: Detection data as dictionary or XML Element
412
+ :return: CVE ID string
413
+ :rtype: str
414
+ """
415
+ if detection is None:
416
+ return ""
417
+
418
+ # Convert XML to dict if needed
419
+ if not isinstance(detection, dict) and hasattr(detection, "tag"):
420
+ detection = self._xml_element_to_dict(detection)
421
+
422
+ # Use dict extraction method
423
+ return self._extract_cve_id_from_dict(detection)
424
+
425
+ def _extract_issue_data_from_dict(self, detection: Optional[Dict[str, Any]], finding_data: Dict[str, Any]) -> None:
426
+ """
427
+ Extract issue data from dictionary and update finding_data in place.
428
+
429
+ :param Optional[Dict[str, Any]] detection: Detection data
430
+ :param Dict[str, Any] finding_data: Finding data to update
431
+ :return: None
432
+ """
433
+ if not detection:
434
+ detection = {}
435
+
436
+ qid = finding_data.get("qid", "Unknown")
437
+ issue_data = detection.get("ISSUE_DATA", {})
438
+
439
+ # Default values
440
+ finding_data["title"] = f"Qualys Vulnerability QID-{qid}"
441
+ finding_data["diagnosis"] = NO_DESCRIPTION
442
+ finding_data["solution"] = NO_REMEDIATION
443
+
444
+ # Update with actual values if present
445
+ if issue_data:
446
+ if isinstance(issue_data, dict):
447
+ finding_data["title"] = issue_data.get("TITLE", finding_data["title"])
448
+ finding_data["diagnosis"] = issue_data.get("DIAGNOSIS", finding_data["diagnosis"])
449
+ finding_data["solution"] = issue_data.get("SOLUTION", finding_data["solution"])
450
+ else:
451
+ logger.warning(f"Expected dictionary for ISSUE_DATA, got {type(issue_data)}")
452
+
453
+ # Ensure values are strings
454
+ finding_data["title"] = (
455
+ str(finding_data["title"]) if finding_data["title"] else f"Qualys Vulnerability QID-{qid}"
456
+ )
457
+ finding_data["diagnosis"] = str(finding_data["diagnosis"]) if finding_data["diagnosis"] else NO_DESCRIPTION
458
+ finding_data["solution"] = str(finding_data["solution"]) if finding_data["solution"] else NO_REMEDIATION
459
+
460
+ def _extract_issue_data_from_xml(
461
+ self, detection: Optional[Union[Dict[str, Any], ET.Element]], finding_data: Dict[str, Any]
462
+ ) -> None:
463
+ """
464
+ Deprecated: Convert to dict first then use _extract_issue_data_from_dict.
465
+
466
+ :param Optional[Union[Dict[str, Any], ET.Element]] detection: Detection data as dictionary or XML Element
467
+ :param Dict[str, Any] finding_data: Finding data to update
468
+ :return: None
469
+ """
470
+ if detection is None:
471
+ # Set default values
472
+ qid = finding_data["qid"]
473
+ finding_data["title"] = f"Qualys Vulnerability QID-{qid}"
474
+ finding_data["diagnosis"] = NO_DESCRIPTION
475
+ finding_data["solution"] = NO_REMEDIATION
476
+ return
477
+
478
+ # Convert XML to dict if needed
479
+ if not isinstance(detection, dict) and hasattr(detection, "tag"):
480
+ detection = self._xml_element_to_dict(detection)
481
+
482
+ # Use dict extraction method
483
+ self._extract_issue_data_from_dict(detection, finding_data)
484
+
485
+ def _build_evidence(self, qid: str, host_id: str, first_found: str, last_found: str, results: Optional[str]) -> str:
486
+ """
487
+ Build evidence string from finding data.
488
+
489
+ :param str qid: QID identifier
490
+ :param str host_id: Host ID
491
+ :param str first_found: First found datetime
492
+ :param str last_found: Last found datetime
493
+ :param Optional[str] results: Results data
494
+ :return: Formatted evidence string
495
+ :rtype: str
496
+ """
497
+ evidence_parts = [
498
+ f"QID: {qid}",
499
+ f"Host ID: {host_id}",
500
+ f"First Found: {first_found}",
501
+ f"Last Found: {last_found}",
502
+ ]
503
+
504
+ if results:
505
+ evidence_parts.append(f"\nResults:\n{results}")
506
+
507
+ return "\n".join(evidence_parts)
508
+
509
+ def _create_finding_object(self, finding_data: Dict[str, Any], host_id: str) -> IntegrationFinding:
510
+ """
511
+ Create IntegrationFinding object from extracted finding data.
512
+
513
+ :param Dict[str, Any] finding_data: Finding data dictionary
514
+ :param str host_id: Host ID
515
+ :return: IntegrationFinding object
516
+ :rtype: IntegrationFinding
517
+ """
518
+ if not finding_data:
519
+ finding_data = {} # Ensure we have at least an empty dict
520
+
521
+ severity_value = self.finding_severity_map.get(finding_data.get("severity", "0"), IssueSeverity.Moderate.value)
522
+
523
+ # Get current time for any missing date fields
524
+ current_time = self.scan_date or get_current_datetime()
525
+ qid = finding_data.get("qid", "Unknown")
526
+
527
+ return IntegrationFinding(
528
+ title=finding_data.get("title", f"Qualys Vulnerability QID-{qid}"),
529
+ description=finding_data.get("diagnosis", NO_DESCRIPTION),
530
+ severity=severity_value,
531
+ status=self.get_finding_status(finding_data.get("status", "New")),
532
+ cvss_v3_score=finding_data.get("cvss_v3_score"),
533
+ cvss_v3_vector=finding_data.get("cvss_v3_vector", ""),
534
+ cvss_v2_score=finding_data.get("cvss_v2_score"),
535
+ cvss_v2_vector=finding_data.get("cvss_v2_vector", ""),
536
+ plugin_name=f"QID-{qid}",
537
+ plugin_id=self.title,
538
+ asset_identifier=host_id,
539
+ category="Vulnerability",
540
+ cve=finding_data.get("cve_id", ""),
541
+ control_labels=[f"QID-{qid}"],
542
+ evidence=finding_data.get("evidence", "No evidence available"),
543
+ identified_risk=finding_data.get("title", f"Qualys Vulnerability QID-{qid}"),
544
+ recommendation_for_mitigation=finding_data.get("solution", NO_REMEDIATION),
545
+ scan_date=current_time,
546
+ first_seen=finding_data.get("first_found", current_time),
547
+ last_seen=finding_data.get("last_found", current_time),
548
+ external_id=finding_data.get("unique_id", f"QID-{qid}-{host_id}"),
549
+ due_date=issue_due_date(
550
+ severity=severity_value,
551
+ created_date=finding_data.get("first_found", current_time),
552
+ title=self.title,
553
+ config=self.app.config,
554
+ ),
555
+ )
556
+
557
+ def _normalize_finding_dates(self, finding):
558
+ """Ensure all dates are strings for JSON serialization."""
559
+ date_fields = ["scan_date", "first_seen", "last_seen", "due_date"]
560
+
561
+ for field in date_fields:
562
+ value = getattr(finding, field, None)
563
+ if not isinstance(value, str) and value:
564
+ setattr(finding, field, value.isoformat() if hasattr(value, "isoformat") else str(value))
565
+
566
+ def _get_findings_data_from_file(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
567
+ """
568
+ Extract findings data from Qualys XML data.
569
+
570
+ :param Dict[str, Any] data: The data from the XML
571
+ :return: List of finding items
572
+ :rtype: List[Dict[str, Any]]
573
+ """
574
+ host_list = data.get("HOST_LIST_VM_DETECTION_OUTPUT", {}).get("RESPONSE", {}).get("HOST_LIST")
575
+ hosts = host_list.get("HOST", []) if host_list else []
576
+
577
+ if isinstance(hosts, dict):
578
+ hosts = [hosts]
579
+
580
+ findings = []
581
+ for host in hosts:
582
+ host_id = host.get("ID", "")
583
+ detection_list = host.get("DETECTION_LIST", {})
584
+ detections = detection_list.get("DETECTION", []) if detection_list else []
585
+
586
+ if isinstance(detections, dict):
587
+ detections = [detections]
588
+
589
+ for detection in detections:
590
+ detection["host_id"] = host_id # Add host_id to each detection
591
+ findings.append(detection)
592
+
593
+ return findings
594
+
595
+ def _build_evidence_from_detection(self, item: Dict[str, Any], host: Dict[str, Any]) -> str:
596
+ """
597
+ Build evidence string from detection data.
598
+
599
+ :param Dict[str, Any] item: Detection data
600
+ :param Dict[str, Any] host: Host data
601
+ :return: Formatted evidence string
602
+ :rtype: str
603
+ """
604
+ evidence_parts = [
605
+ f"QID: {item.get('QID', 'Unknown')}",
606
+ f"Host: {host.get('IP', 'Unknown')} ({host.get('DNS', 'Unknown')})",
607
+ f"OS: {host.get('OS', 'Unknown')}",
608
+ f"First Found: {item.get('FIRST_FOUND_DATETIME', 'Unknown')}",
609
+ f"Last Found: {item.get('LAST_FOUND_DATETIME', 'Unknown')}",
610
+ ]
611
+
612
+ if item.get("RESULTS"):
613
+ evidence_parts.append(f"\nResults:\n{item.get('RESULTS')}")
614
+
615
+ return "\n".join(evidence_parts)
616
+
617
+ def _build_remediation(self, item: Dict[str, Any]) -> str:
618
+ """
619
+ Build remediation string from detection data.
620
+
621
+ :param Dict[str, Any] item: Detection data
622
+ :return: Formatted remediation string
623
+ :rtype: str
624
+ """
625
+ if item.get("SOLUTION"):
626
+ return item.get("SOLUTION")
627
+ return "No remediation information available."
628
+
629
+ def _get_cve_id(self, item: Dict[str, Any]) -> str:
630
+ """
631
+ Extract CVE ID from detection data.
632
+
633
+ :param Dict[str, Any] item: Detection data
634
+ :return: CVE ID if available
635
+ :rtype: str
636
+ """
637
+ # Check for CVEs in the item
638
+ if item.get("CVE_ID_LIST", {}).get("CVE_ID"):
639
+ cve_data = item.get("CVE_ID_LIST", {}).get("CVE_ID", [])
640
+ if isinstance(cve_data, list) and cve_data:
641
+ return cve_data[0]
642
+ elif isinstance(cve_data, str):
643
+ return cve_data
644
+
645
+ return ""
646
+
647
+ def fetch_assets_and_findings(self, file_path: str = None, empty_files: bool = True):
648
+ """
649
+ Fetch assets and findings from Qualys Total Cloud JSONL file or XML data.
650
+ This method orchestrates the process flow based on input type.
651
+
652
+ :param str file_path: Path to source file or directory (for compatibility with parent class)
653
+ :param bool empty_files: Whether to empty both output files before writing (for compatibility)
654
+ :return: None or Tuple of (assets_iterator, findings_iterator) for compatibility with parent class
655
+ """
656
+ if file_path:
657
+ self.file_path = file_path
658
+ self.empty_files = empty_files
659
+
660
+ self._verify_file_path()
661
+ self._prepare_output_files()
662
+
663
+ try:
664
+ if self.xml_data:
665
+ self._process_xml_data()
666
+ else:
667
+ self._process_jsonl_file(file_path, empty_files)
668
+
669
+ # For compatibility with parent class that returns iterators
670
+ if self.xml_data:
671
+ assets_iterator = self._yield_items_from_jsonl(self.ASSETS_FILE, IntegrationAsset)
672
+ findings_iterator = self._yield_items_from_jsonl(self.FINDINGS_FILE, IntegrationFinding)
673
+ return assets_iterator, findings_iterator
674
+ except Exception as e:
675
+ logger.error(f"Error fetching assets and findings: {str(e)}")
676
+ logger.error(traceback.format_exc())
677
+ raise
678
+
679
+ def _verify_file_path(self):
680
+ """Verify that the file path exists if provided."""
681
+ if self.file_path and not os.path.isfile(self.file_path):
682
+ logger.error(f"QualysTotalCloudJSONLIntegration file path does not exist: {self.file_path}")
683
+ raise FileNotFoundError(f"File path does not exist: {self.file_path}")
684
+
685
+ def _prepare_output_files(self):
686
+ """Create output directories and clear existing output files."""
687
+ # Create output directory if it doesn't exist
688
+ os.makedirs(os.path.dirname(self.ASSETS_FILE), exist_ok=True)
689
+ os.makedirs(os.path.dirname(self.FINDINGS_FILE), exist_ok=True)
690
+
691
+ # Clear any existing output files
692
+ if os.path.exists(self.ASSETS_FILE):
693
+ os.remove(self.ASSETS_FILE)
694
+ if os.path.exists(self.FINDINGS_FILE):
695
+ os.remove(self.FINDINGS_FILE)
696
+
697
+ def _process_xml_data(self):
698
+ """Process XML data from string or dictionary format."""
699
+ logger.info("Processing XML data directly")
700
+
701
+ if isinstance(self.xml_data, str):
702
+ self._process_xml_string()
703
+ elif isinstance(self.xml_data, dict):
704
+ self._process_xml_dict()
705
+ else:
706
+ logger.error(f"Unsupported XML data type: {type(self.xml_data)}")
707
+
708
+ def _process_xml_string(self):
709
+ """Process XML data provided as a string."""
710
+ logger.debug("Parsing XML string data")
711
+ try:
712
+ # Convert XML string to dictionary first, then process it
713
+ xml_dict = self._convert_xml_string_to_dict(self.xml_data)
714
+ self.xml_data = xml_dict # Replace string with dict for consistent processing
715
+ self._process_xml_dict() # Use the dict processing pathway
716
+ except Exception as e:
717
+ logger.error(f"Error processing XML string: {str(e)}")
718
+ logger.debug(traceback.format_exc())
719
+
720
+ def _convert_xml_string_to_dict(self, xml_string: str) -> Dict[str, Any]:
721
+ """
722
+ Convert an XML string to a dictionary.
723
+
724
+ :param str xml_string: XML string to convert
725
+ :return: Dictionary representation of the XML
726
+ :rtype: Dict[str, Any]
727
+ """
728
+ try:
729
+ root = ET.fromstring(xml_string)
730
+ return self._xml_element_to_dict(root)
731
+ except ET.ParseError as e:
732
+ logger.error(f"Error parsing XML: {str(e)}")
733
+ return {}
734
+
735
+ def _process_xml_dict(self):
736
+ """Process XML data provided as a dictionary."""
737
+ logger.debug("Using already parsed XML data (dict)")
738
+
739
+ # Find all hosts in the XML data dictionary
740
+ hosts_data = self._extract_hosts_from_dict()
741
+ if not hosts_data:
742
+ return
743
+
744
+ num_hosts = len(hosts_data)
745
+ logger.info(f"Found {num_hosts} hosts in XML dictionary data")
746
+
747
+ # Extract all findings
748
+ all_findings = self._extract_findings_from_hosts(hosts_data)
749
+ num_findings = len(all_findings)
750
+ logger.info(f"Found {num_findings} total findings in XML dictionary data")
751
+
752
+ # Process assets and findings
753
+ self._process_dict_assets_and_findings(hosts_data, all_findings)
754
+
755
+ def _extract_hosts_from_dict(self):
756
+ """Extract host data from XML dictionary structure."""
757
+ hosts_data = (
758
+ self.xml_data.get("HOST_LIST_VM_DETECTION_OUTPUT", {})
759
+ .get("RESPONSE", {})
760
+ .get("HOST_LIST", {})
761
+ .get("HOST", [])
762
+ )
763
+
764
+ # Normalize to ensure hosts_data is always a list
765
+ if isinstance(hosts_data, dict):
766
+ hosts_data = [hosts_data]
767
+
768
+ return hosts_data
769
+
770
+ @staticmethod
771
+ def _extract_findings_from_hosts(hosts_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
772
+ """Extract all findings from host data dictionaries.
773
+ :param List[Dict[str, Any]] hosts_data: List of host data dictionaries
774
+ :return: List of findings dictionaries
775
+ :rtype: List[Dict[str, Any]]
776
+ """
777
+ all_findings = []
778
+ for host in hosts_data:
779
+ host_id = host.get("ID", "")
780
+ detections = host.get("DETECTION_LIST", {}).get("DETECTION", [])
781
+
782
+ # Normalize to ensure detections is always a list
783
+ if isinstance(detections, dict):
784
+ detections = [detections]
785
+
786
+ for detection in detections:
787
+ detection["host_id"] = host_id
788
+ all_findings.append(detection)
789
+
790
+ return all_findings
791
+
792
+ def _process_dict_assets_and_findings(self, hosts_data, all_findings):
793
+ """Process assets and findings from dictionary data."""
794
+ with open(self.ASSETS_FILE, "w") as assets_file, open(self.FINDINGS_FILE, "w") as findings_file:
795
+ self._write_assets_from_dict(assets_file, hosts_data)
796
+ self._write_findings_from_dict(findings_file, all_findings)
797
+
798
+ def _write_assets_from_dict(self, assets_file, hosts_data):
799
+ """Write assets from dictionary data to JSONL file."""
800
+ assets_written = 0
801
+ for host in hosts_data:
802
+ try:
803
+ asset = self.parse_asset(host=host)
804
+ self._write_item(assets_file, asset)
805
+ assets_written += 1
806
+ except Exception as e:
807
+ logger.error(f"Error processing asset: {str(e)}")
808
+ logger.debug(traceback.format_exc())
809
+
810
+ logger.info(f"Wrote {assets_written} assets to {self.ASSETS_FILE}")
811
+
812
+ def _write_findings_from_dict(self, findings_file, all_findings):
813
+ """Write findings from dictionary data to JSONL file."""
814
+ findings_written = 0
815
+ for finding in all_findings:
816
+ try:
817
+ host_id = finding.get("host_id", "")
818
+ parsed_finding = self.parse_finding(detection=finding, host_id=host_id)
819
+ self._write_item(findings_file, parsed_finding)
820
+ findings_written += 1
821
+ except Exception as e:
822
+ logger.error(f"Error processing finding: {str(e)}")
823
+ logger.debug(traceback.format_exc())
824
+
825
+ logger.info(f"Wrote {findings_written} findings to {self.FINDINGS_FILE}")
826
+
827
+ def _process_xml_elements(self, hosts):
828
+ """Process XML element hosts and detections with progress tracking."""
829
+ # Convert XML elements to dictionaries first
830
+ hosts_dict = self._convert_xml_elements_to_dict(hosts)
831
+ all_findings = []
832
+
833
+ # Extract all findings with host_ids
834
+ for host in hosts_dict:
835
+ host_id = host.get("ID", "")
836
+ detections = host.get("DETECTION_LIST", {}).get("DETECTION", [])
837
+
838
+ # Normalize to ensure detections is always a list
839
+ if isinstance(detections, dict):
840
+ detections = [detections]
841
+
842
+ # Add host_id to each detection and collect
843
+ for detection in detections:
844
+ detection["host_id"] = host_id
845
+ all_findings.append(detection)
846
+
847
+ # Now process using the dictionary methods
848
+ with Progress(
849
+ SpinnerColumn(),
850
+ TextColumn("[progress.description]{task.description}"),
851
+ BarColumn(),
852
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
853
+ TextColumn("({task.completed}/{task.total})"),
854
+ TimeElapsedColumn(),
855
+ ) as progress:
856
+ # Setup progress tasks
857
+ asset_task = progress.add_task("[cyan]Processing assets...", total=len(hosts_dict))
858
+ finding_task = progress.add_task("[green]Processing findings...", total=len(all_findings))
859
+
860
+ # Process using dictionary methods with progress tracking
861
+ with open(self.ASSETS_FILE, "w") as assets_file, open(self.FINDINGS_FILE, "w") as findings_file:
862
+ self._process_dict_assets_with_progress(hosts_dict, assets_file, progress, asset_task)
863
+ # Use the findings list we extracted instead of trying to extract again
864
+ self._process_findings_list_with_progress(all_findings, findings_file, progress, finding_task)
865
+
866
+ def _process_dict_assets_with_progress(self, hosts_dict, assets_file, progress, asset_task):
867
+ """Process assets from dictionaries with progress tracking."""
868
+ assets_written = 0
869
+ host_ids_processed = set()
870
+
871
+ for host in hosts_dict:
872
+ progress.update(asset_task, advance=1)
873
+ try:
874
+ host_id = host.get("ID", "")
875
+ if host_id and host_id not in host_ids_processed:
876
+ asset = self.parse_asset(host=host)
877
+ self._write_item(assets_file, asset)
878
+ assets_written += 1
879
+ host_ids_processed.add(host_id)
880
+ except Exception as e:
881
+ logger.error(f"Error parsing asset: {str(e)}")
882
+ logger.debug(traceback.format_exc())
883
+
884
+ logger.info(f"Wrote {assets_written} unique assets to {self.ASSETS_FILE}")
885
+
886
+ def _process_findings_list_with_progress(
887
+ self, findings: List[Dict[str, Any]], findings_file: TextIO, progress: Progress, finding_task: TaskID
888
+ ) -> None:
889
+ """
890
+ Process a list of finding dictionaries with progress tracking.
891
+
892
+ :param List[Dict[str, Any]] findings: List of finding dictionaries
893
+ :param TextIO findings_file: Open file handle for writing findings
894
+ :param Progress progress: Progress tracker
895
+ :param TaskID finding_task: Task ID for progress tracking
896
+ :return: None
897
+ """
898
+ findings_written = 0
899
+ finding_ids_processed = set()
900
+
901
+ for finding in findings:
902
+ progress.update(finding_task, advance=1)
903
+
904
+ try:
905
+ host_id = finding.get("host_id", "")
906
+ # Get unique finding ID
907
+ unique_id = self._get_detection_unique_id(finding, host_id)
908
+
909
+ if unique_id and unique_id not in finding_ids_processed:
910
+ parsed_finding = self.parse_finding(detection=finding, host_id=host_id)
911
+ self._write_item(findings_file, parsed_finding)
912
+ findings_written += 1
913
+ finding_ids_processed.add(unique_id)
914
+ except Exception as e:
915
+ logger.error(f"Error parsing finding: {str(e)}")
916
+ logger.debug(traceback.format_exc())
917
+
918
+ logger.info(f"Wrote {findings_written} unique findings to {self.FINDINGS_FILE}")
919
+
920
+ def _process_jsonl_file(self, file_path: Optional[str] = None, empty_files: bool = True) -> None:
921
+ """
922
+ Process JSONL file using parent class implementation.
923
+
924
+ :param Optional[str] file_path: Path to JSONL file to process, defaults to None
925
+ :param bool empty_files: Whether to empty files before processing, defaults to True
926
+ :return: None
927
+ """
928
+ logger.info(f"Processing JSONL file: {self.file_path}")
929
+ super().fetch_assets_and_findings(file_path, empty_files)
930
+
931
+ def update_regscale_assets(self, assets_iterator: Iterator[IntegrationAsset]) -> int:
932
+ """
933
+ Update RegScale with assets.
934
+
935
+ :param Iterator[IntegrationAsset] assets_iterator: Iterator of assets
936
+ :return: Number of assets created
937
+ :rtype: int
938
+ """
939
+ # Use the parent class implementation
940
+ return super().update_regscale_assets(assets_iterator)
941
+
942
+ def _extract_host_and_detections(self, host):
943
+ """Extract host ID and detections from host data.
944
+
945
+ :param host: Host data (dict or XML Element)
946
+ :return: Tuple of (host_id, detections)
947
+ """
948
+ # Convert XML to dict if needed
949
+ if not isinstance(host, dict) and hasattr(host, "tag"):
950
+ host = self._xml_element_to_dict(host)
951
+
952
+ host_id = host.get("ID", "")
953
+ detections = host.get("DETECTION_LIST", {}).get("DETECTION", [])
954
+
955
+ # Normalize to ensure detections is always a list
956
+ if isinstance(detections, dict):
957
+ detections = [detections]
958
+
959
+ return host_id, detections
960
+
961
+ def _convert_xml_elements_to_dict(self, elements: List[ET.Element]) -> List[Dict[str, Any]]:
962
+ """
963
+ Convert XML elements to a list of dictionaries.
964
+
965
+ :param List[ET.Element] elements: List of XML Element objects
966
+ :return: List of dictionaries with the same data
967
+ :rtype: List[Dict[str, Any]]
968
+ """
969
+ result: List[Dict[str, Any]] = []
970
+ for element in elements:
971
+ result.append(self._xml_element_to_dict(element))
972
+ return result
973
+
974
+ def _xml_element_to_dict(self, element: Optional[ET.Element]) -> Dict[str, Any]:
975
+ """
976
+ Convert a single XML element to a dictionary with all its data.
977
+
978
+ :param Optional[ET.Element] element: XML Element object
979
+ :return: Dictionary with the element's data
980
+ :rtype: Dict[str, Any]
981
+ """
982
+ if element is None:
983
+ return {}
984
+
985
+ result: Dict[str, Any] = {}
986
+
987
+ # Add attributes
988
+ for key, value in element.attrib.items():
989
+ result[key] = value
990
+
991
+ # Add text content if element has no children
992
+ if len(element) == 0:
993
+ text = element.text
994
+ if text is not None and text.strip():
995
+ # If this is a leaf node with text, just return the text
996
+ return text.strip()
997
+
998
+ # Add child elements
999
+ for child in element:
1000
+ child_data = self._xml_element_to_dict(child)
1001
+ tag = child.tag
1002
+
1003
+ # Handle multiple elements with the same tag
1004
+ if tag in result:
1005
+ if isinstance(result[tag], list):
1006
+ result[tag].append(child_data)
1007
+ else:
1008
+ result[tag] = [result[tag], child_data]
1009
+ else:
1010
+ result[tag] = child_data
1011
+
1012
+ return result
1013
+
1014
+ def _get_detection_unique_id(self, detection: Union[Dict[str, Any], ET.Element], host_id: str) -> str:
1015
+ """
1016
+ Get a unique identifier for a detection.
1017
+
1018
+ :param Union[Dict[str, Any], ET.Element] detection: Detection data as dictionary or XML Element
1019
+ :param str host_id: Host ID
1020
+ :return: Unique identifier string
1021
+ :rtype: str
1022
+ """
1023
+ # Convert XML to dict if needed
1024
+ if not isinstance(detection, dict) and hasattr(detection, "tag"):
1025
+ detection = self._xml_element_to_dict(detection)
1026
+
1027
+ qid = detection.get("QID", "")
1028
+ unique_id = detection.get("UNIQUE_VULN_ID", f"{host_id}-{qid}")
1029
+
1030
+ return unique_id
1031
+
1032
+ def get_finding_status(self, status: Optional[str]) -> IssueStatus:
1033
+ """
1034
+ Convert the Qualys status to a RegScale issue status.
1035
+
1036
+ :param Optional[str] status: The status from Qualys
1037
+ :return: RegScale IssueStatus
1038
+ :rtype: IssueStatus
1039
+ """
1040
+ if not status:
1041
+ return IssueStatus.Open
1042
+
1043
+ # Normalize the status string to handle case variations
1044
+ normalized_status = status.strip().lower() if isinstance(status, str) else ""
1045
+
1046
+ # Map to our status values
1047
+ if normalized_status in ("fixed", "closed"):
1048
+ return IssueStatus.Closed
1049
+
1050
+ # Default to Open for any unknown status
1051
+ return IssueStatus.Open