regscale-cli 6.18.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.
- regscale/__init__.py +1 -1
- regscale/integrations/api_paginator.py +932 -0
- regscale/integrations/api_paginator_example.py +348 -0
- regscale/integrations/commercial/__init__.py +11 -10
- regscale/integrations/commercial/{qualys.py → qualys/__init__.py} +756 -105
- regscale/integrations/commercial/qualys/scanner.py +1051 -0
- regscale/integrations/commercial/qualys/variables.py +21 -0
- regscale/integrations/commercial/sicura/api.py +1 -0
- regscale/integrations/commercial/stigv2/click_commands.py +36 -8
- regscale/integrations/commercial/stigv2/stig_integration.py +63 -9
- regscale/integrations/commercial/tenablev2/__init__.py +9 -0
- regscale/integrations/commercial/tenablev2/authenticate.py +23 -2
- regscale/integrations/commercial/tenablev2/commands.py +779 -0
- regscale/integrations/commercial/tenablev2/jsonl_scanner.py +1999 -0
- regscale/integrations/commercial/tenablev2/sc_scanner.py +600 -0
- regscale/integrations/commercial/tenablev2/scanner.py +7 -5
- regscale/integrations/commercial/tenablev2/utils.py +21 -4
- regscale/integrations/commercial/tenablev2/variables.py +4 -0
- regscale/integrations/jsonl_scanner_integration.py +523 -142
- regscale/integrations/scanner_integration.py +102 -26
- regscale/integrations/transformer/__init__.py +17 -0
- regscale/integrations/transformer/data_transformer.py +445 -0
- regscale/integrations/transformer/mappings/__init__.py +8 -0
- regscale/integrations/variables.py +2 -0
- regscale/models/__init__.py +5 -2
- regscale/models/integration_models/cisa_kev_data.json +5 -5
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/regscale_models/asset.py +5 -2
- regscale/models/regscale_models/file.py +5 -2
- regscale/regscale.py +3 -1
- {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/METADATA +1 -1
- {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/RECORD +44 -28
- tests/regscale/core/test_version.py +22 -0
- tests/regscale/integrations/__init__.py +0 -0
- tests/regscale/integrations/test_api_paginator.py +597 -0
- tests/regscale/integrations/test_integration_mapping.py +60 -0
- tests/regscale/integrations/test_issue_creation.py +317 -0
- tests/regscale/integrations/test_issue_due_date.py +46 -0
- tests/regscale/integrations/transformer/__init__.py +0 -0
- tests/regscale/integrations/transformer/test_data_transformer.py +850 -0
- regscale/integrations/commercial/tenablev2/click.py +0 -1641
- {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.0.dist-info}/entry_points.txt +0 -0
- {regscale_cli-6.18.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
|