regscale-cli 6.23.0.1__py3-none-any.whl → 6.24.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 (43) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +2 -0
  3. regscale/integrations/commercial/__init__.py +1 -0
  4. regscale/integrations/commercial/sarif/sarif_converter.py +1 -1
  5. regscale/integrations/commercial/wizv2/click.py +109 -2
  6. regscale/integrations/commercial/wizv2/compliance_report.py +1485 -0
  7. regscale/integrations/commercial/wizv2/constants.py +72 -2
  8. regscale/integrations/commercial/wizv2/data_fetcher.py +61 -0
  9. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  10. regscale/integrations/commercial/wizv2/issue.py +775 -27
  11. regscale/integrations/commercial/wizv2/policy_compliance.py +599 -181
  12. regscale/integrations/commercial/wizv2/reports.py +243 -0
  13. regscale/integrations/commercial/wizv2/scanner.py +668 -245
  14. regscale/integrations/compliance_integration.py +304 -51
  15. regscale/integrations/due_date_handler.py +210 -0
  16. regscale/integrations/public/cci_importer.py +444 -0
  17. regscale/integrations/scanner_integration.py +718 -153
  18. regscale/models/integration_models/CCI_List.xml +1 -0
  19. regscale/models/integration_models/cisa_kev_data.json +18 -3
  20. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  21. regscale/models/regscale_models/form_field_value.py +1 -1
  22. regscale/models/regscale_models/milestone.py +1 -0
  23. regscale/models/regscale_models/regscale_model.py +225 -60
  24. regscale/models/regscale_models/security_plan.py +3 -2
  25. regscale/regscale.py +7 -0
  26. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/METADATA +9 -9
  27. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/RECORD +43 -26
  28. tests/fixtures/test_fixture.py +13 -8
  29. tests/regscale/integrations/public/__init__.py +0 -0
  30. tests/regscale/integrations/public/test_alienvault.py +220 -0
  31. tests/regscale/integrations/public/test_cci.py +458 -0
  32. tests/regscale/integrations/public/test_cisa.py +1021 -0
  33. tests/regscale/integrations/public/test_emass.py +518 -0
  34. tests/regscale/integrations/public/test_fedramp.py +851 -0
  35. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  36. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  37. tests/regscale/integrations/public/test_oscal.py +453 -0
  38. tests/regscale/models/test_form_field_value_integration.py +304 -0
  39. tests/regscale/models/test_module_integration.py +582 -0
  40. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/LICENSE +0 -0
  41. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/WHEEL +0 -0
  42. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/entry_points.txt +0 -0
  43. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Due Date Handler for Scanner Integrations"""
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import Any, Dict, Optional
8
+
9
+ from regscale.core.app.application import Application
10
+ from regscale.core.utils.date import date_str, get_day_increment
11
+ from regscale.integrations.public.cisa import pull_cisa_kev
12
+ from regscale.models import regscale_models
13
+ from regscale.utils.threading import ThreadSafeDict
14
+
15
+ logger = logging.getLogger("regscale")
16
+
17
+
18
+ class DueDateHandler:
19
+ """
20
+ Handles due date calculations for scanner integrations based on:
21
+ 1. Init.yaml timeline configurations per integration
22
+ 2. KEV (Known Exploited Vulnerabilities) dates from CISA
23
+ 3. Default severity-based timelines
24
+ """
25
+
26
+ def __init__(self, integration_name: str, config: Optional[Dict[str, Any]] = None):
27
+ """
28
+ Initialize the DueDateHandler for a specific integration
29
+
30
+ :param str integration_name: Name of the integration (e.g., 'wiz', 'qualys', 'tenable')
31
+ :param Optional[Dict[str, Any]] config: Optional config override, uses Application config if None
32
+ """
33
+ self.integration_name = integration_name.lower()
34
+ self.config = config or Application().config
35
+ self._kev_data: Optional[ThreadSafeDict] = None
36
+
37
+ # Default due date timelines (days)
38
+ self.default_timelines = {
39
+ regscale_models.IssueSeverity.Critical: 30,
40
+ regscale_models.IssueSeverity.High: 60,
41
+ regscale_models.IssueSeverity.Moderate: 120,
42
+ regscale_models.IssueSeverity.Low: 364,
43
+ }
44
+
45
+ # Load integration-specific timelines from config
46
+ self.integration_timelines = self._load_integration_timelines()
47
+
48
+ def _load_integration_timelines(self) -> Dict[regscale_models.IssueSeverity, int]:
49
+ """
50
+ Load timeline configurations for this integration from init.yaml
51
+
52
+ :return: Dictionary mapping severity to days
53
+ :rtype: Dict[regscale_models.IssueSeverity, int]
54
+ """
55
+ timelines = self.default_timelines.copy()
56
+
57
+ issues_config = self.config.get("issues", {})
58
+ integration_config = issues_config.get(self.integration_name, {})
59
+
60
+ if integration_config:
61
+ logger.debug(f"Found timeline config for {self.integration_name}: {integration_config}")
62
+
63
+ # Map config keys to severity levels
64
+ severity_mapping = {
65
+ "critical": regscale_models.IssueSeverity.Critical,
66
+ "high": regscale_models.IssueSeverity.High,
67
+ "moderate": regscale_models.IssueSeverity.Moderate,
68
+ "medium": regscale_models.IssueSeverity.Moderate, # Some integrations use 'medium'
69
+ "low": regscale_models.IssueSeverity.Low,
70
+ }
71
+
72
+ for config_key, severity in severity_mapping.items():
73
+ if config_key in integration_config:
74
+ timelines[severity] = integration_config[config_key]
75
+
76
+ return timelines
77
+
78
+ def _get_kev_data(self) -> ThreadSafeDict:
79
+ """
80
+ Get KEV data from CISA, using cache if available
81
+
82
+ :return: Thread-safe dictionary containing KEV data
83
+ :rtype: ThreadSafeDict
84
+ """
85
+ if self._kev_data is None:
86
+ try:
87
+ kev_data = pull_cisa_kev()
88
+ self._kev_data = ThreadSafeDict()
89
+ self._kev_data.update(kev_data)
90
+ logger.debug("Loaded KEV data from CISA")
91
+ except Exception as e:
92
+ logger.warning(f"Failed to load KEV data: {e}")
93
+ self._kev_data = ThreadSafeDict()
94
+
95
+ return self._kev_data
96
+
97
+ def _should_use_kev(self) -> bool:
98
+ """
99
+ Check if this integration should use KEV dates
100
+
101
+ :return: True if KEV should be used for this integration
102
+ :rtype: bool
103
+ """
104
+ issues_config = self.config.get("issues", {})
105
+ integration_config = issues_config.get(self.integration_name, {})
106
+ return integration_config.get("useKev", True) # Default to True if not specified
107
+
108
+ def _get_kev_due_date(self, cve: str) -> Optional[str]:
109
+ """
110
+ Get the KEV due date for a specific CVE
111
+
112
+ :param str cve: The CVE identifier
113
+ :return: KEV due date string if found, None otherwise
114
+ :rtype: Optional[str]
115
+ """
116
+ if not self._should_use_kev() or not cve:
117
+ return None
118
+
119
+ kev_data = self._get_kev_data()
120
+
121
+ # Find the KEV entry for this CVE
122
+ kev_entry = next(
123
+ (entry for entry in kev_data.get("vulnerabilities", []) if entry.get("cveID", "").upper() == cve.upper()),
124
+ None,
125
+ )
126
+
127
+ if kev_entry:
128
+ kev_due_date = kev_entry.get("dueDate")
129
+ if kev_due_date:
130
+ logger.debug(f"Found KEV due date for {cve}: {kev_due_date}")
131
+ return kev_due_date
132
+
133
+ return None
134
+
135
+ def calculate_due_date(
136
+ self,
137
+ severity: regscale_models.IssueSeverity,
138
+ created_date: str,
139
+ cve: Optional[str] = None,
140
+ title: Optional[str] = None,
141
+ ) -> str:
142
+ """
143
+ Calculate the due date for an issue based on severity, KEV status, and integration config
144
+
145
+ :param regscale_models.IssueSeverity severity: The severity of the issue
146
+ :param str created_date: The creation date of the issue
147
+ :param Optional[str] cve: The CVE identifier (if applicable)
148
+ :param Optional[str] title: The title of the issue (for additional context)
149
+ :return: The calculated due date string
150
+ :rtype: str
151
+ """
152
+ # First, check if this CVE has a KEV due date
153
+ if cve:
154
+ kev_due_date = self._get_kev_due_date(cve)
155
+ if kev_due_date:
156
+ # Parse the KEV due date and created date
157
+ try:
158
+ from dateutil.parser import parse as date_parse
159
+
160
+ kev_date = date_parse(kev_due_date).date()
161
+ created_dt = date_parse(created_date).date()
162
+
163
+ # If KEV due date is after creation date, use KEV date
164
+ # If KEV due date is before creation date, add the difference to creation date
165
+ if kev_date >= created_dt:
166
+ logger.debug(f"Using KEV due date {kev_due_date} for CVE {cve}")
167
+ return kev_due_date
168
+ else:
169
+ # KEV date has passed, calculate new due date from creation
170
+ days_diff = (created_dt - kev_date).days
171
+ # Give at least 30 days from creation for critical KEV items
172
+ adjusted_days = max(30, days_diff)
173
+ due_date = date_str(get_day_increment(start=created_date, days=adjusted_days))
174
+ logger.debug(f"KEV date passed, using adjusted due date {due_date} for CVE {cve}")
175
+ return due_date
176
+
177
+ except Exception as e:
178
+ logger.warning(f"Failed to parse KEV due date {kev_due_date}: {e}")
179
+
180
+ # Fall back to severity-based timeline from integration config
181
+ days = self.integration_timelines.get(severity, self.default_timelines[severity])
182
+ due_date = date_str(get_day_increment(start=created_date, days=days))
183
+
184
+ logger.debug(f"Using {self.integration_name} timeline: {severity.name} = {days} days, due date = {due_date}")
185
+
186
+ return due_date
187
+
188
+ def get_integration_config(self) -> Dict[str, Any]:
189
+ """
190
+ Get the full integration configuration from init.yaml
191
+
192
+ :return: Integration configuration dictionary
193
+ :rtype: Dict[str, Any]
194
+ """
195
+ issues_config = self.config.get("issues", {})
196
+ return issues_config.get(self.integration_name, {})
197
+
198
+ def get_timeline_info(self) -> Dict[str, Any]:
199
+ """
200
+ Get information about current timeline configuration
201
+
202
+ :return: Dictionary with timeline information
203
+ :rtype: Dict[str, Any]
204
+ """
205
+ return {
206
+ "integration_name": self.integration_name,
207
+ "use_kev": self._should_use_kev(),
208
+ "timelines": {severity.name: days for severity, days in self.integration_timelines.items()},
209
+ "config_source": "init.yaml",
210
+ }
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env python
2
+ """RegScale CLI command to normalize CCI data from XML files."""
3
+ import datetime
4
+ import logging
5
+ import xml.etree.ElementTree as ET
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+ import click
9
+
10
+ from regscale.core.app.application import Application
11
+ from regscale.core.app.utils.app_utils import error_and_exit
12
+ from regscale.models.regscale_models import Catalog, SecurityControl, CCI
13
+
14
+ logger = logging.getLogger("regscale")
15
+
16
+ # RegScale date format constant
17
+ REGSCALE_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
18
+
19
+
20
+ class CCIImporter:
21
+ """Imports CCI data from XML files and maps to security controls."""
22
+
23
+ def __init__(self, xml_data: ET.Element, version: str = "5", verbose: bool = False):
24
+ """
25
+ Initialize the CCI importer.
26
+
27
+ :param ET.Element xml_data: The root element of the XML data
28
+ :param str version: NIST version to use for filtering
29
+ :param bool verbose: Whether to output verbose information
30
+ """
31
+ self.xml_data = xml_data
32
+ self.normalized_cci: Dict[str, List[Dict]] = {}
33
+ self.verbose = verbose
34
+ self.reference_version = version
35
+ self._user_context: Optional[Tuple[Optional[str], int]] = None
36
+
37
+ @staticmethod
38
+ def _parse_control_id(ref_index: str) -> str:
39
+ """
40
+ Extract the main control_id from a reference index (e.g., 'AC-1 a 1 (b)' -> 'AC-1').
41
+
42
+ :param str ref_index: Reference index string to parse
43
+ :return: Main control ID
44
+ :rtype: str
45
+ """
46
+ parts = ref_index.strip().split()
47
+ return parts[0] if parts else ""
48
+
49
+ @staticmethod
50
+ def _extract_cci_data(cci_item: ET.Element) -> Tuple[Optional[str], str]:
51
+ """
52
+ Extract CCI ID and definition from CCI item.
53
+
54
+ :param ET.Element cci_item: XML element containing CCI data
55
+ :return: Tuple of (cci_id, definition)
56
+ :rtype: Tuple[Optional[str], str]
57
+ """
58
+ cci_id = cci_item.get("id")
59
+ definition_elem = cci_item.find(".//{http://iase.disa.mil/cci}definition")
60
+ definition = definition_elem.text if definition_elem is not None and definition_elem.text else ""
61
+ return cci_id, definition
62
+
63
+ def _process_references(self, references: List[ET.Element], cci_id: str, definition: str) -> None:
64
+ """
65
+ Process reference elements and add to normalized CCI data.
66
+
67
+ :param List[ET.Element] references: List of reference XML elements
68
+ :param str cci_id: CCI identifier
69
+ :param str definition: CCI definition text
70
+ :rtype: None
71
+ """
72
+ for ref in references:
73
+ if not self._is_valid_reference(ref):
74
+ continue
75
+
76
+ ref_index = ref.get("index")
77
+ if ref_index:
78
+ main_control = self._parse_control_id(ref_index)
79
+ self._add_cci_to_control(main_control, cci_id, definition)
80
+
81
+ def _is_valid_reference(self, ref: ET.Element) -> bool:
82
+ """
83
+ Check if reference matches the target version.
84
+
85
+ :param ET.Element ref: Reference XML element
86
+ :return: True if reference version matches target version
87
+ :rtype: bool
88
+ """
89
+ ref_version = ref.get("version")
90
+ return ref_version is not None and ref_version == self.reference_version
91
+
92
+ def _add_cci_to_control(self, main_control: str, cci_id: str, definition: str) -> None:
93
+ """
94
+ Add CCI data to the normalized structure for a control.
95
+
96
+ :param str main_control: Control identifier
97
+ :param str cci_id: CCI identifier
98
+ :param str definition: CCI definition
99
+ :rtype: None
100
+ """
101
+ if main_control not in self.normalized_cci:
102
+ self.normalized_cci[main_control] = []
103
+ self.normalized_cci[main_control].append({"cci_id": cci_id, "definition": definition})
104
+
105
+ def parse_cci(self) -> None:
106
+ """
107
+ Parse CCI items from XML and normalize them, mapping to parent control only.
108
+
109
+ :rtype: None
110
+ """
111
+ if self.verbose:
112
+ click.echo("Parsing CCI items from XML...")
113
+
114
+ for cci_item in self.xml_data.findall(".//{http://iase.disa.mil/cci}cci_item"):
115
+ cci_id, definition = self._extract_cci_data(cci_item)
116
+ if not cci_id:
117
+ continue
118
+
119
+ references = cci_item.findall(".//{http://iase.disa.mil/cci}reference")
120
+ self._process_references(references, cci_id, definition)
121
+
122
+ @staticmethod
123
+ def _get_catalog(catalog_id: int) -> Catalog:
124
+ """
125
+ Get the catalog with specified ID.
126
+
127
+ :param int catalog_id: ID of the catalog to retrieve
128
+ :return: Catalog instance
129
+ :rtype: Catalog
130
+ :raises SystemExit: If catalog not found
131
+ """
132
+ try:
133
+ catalog = Catalog.get(id=catalog_id)
134
+ if catalog is None:
135
+ error_and_exit(f"Catalog with id {catalog_id} not found. Please ensure the catalog exists.")
136
+ return catalog
137
+ except Exception:
138
+ error_and_exit(f"Catalog with id {catalog_id} not found. Please ensure the catalog exists.")
139
+
140
+ def _get_user_context(self) -> Tuple[Optional[str], int]:
141
+ """
142
+ Get user ID and tenant ID from application config.
143
+
144
+ :return: Tuple of (user_id, tenant_id)
145
+ :rtype: Tuple[Optional[str], int]
146
+ """
147
+ if self._user_context is None:
148
+ app = Application()
149
+ user_id = app.config.get("userId")
150
+ tenant_id = app.config.get("tenantId", 1)
151
+
152
+ try:
153
+ user_id = str(user_id) if user_id else None
154
+ except (TypeError, ValueError):
155
+ user_id = None
156
+ if self.verbose:
157
+ logger.warning("userId in config is not set or invalid; created_by will be None.")
158
+
159
+ # Convert tenant_id to int if it's a string
160
+ try:
161
+ tenant_id = int(tenant_id)
162
+ except (TypeError, ValueError):
163
+ tenant_id = 1
164
+ if self.verbose:
165
+ logger.warning("tenantId in config is not valid; using default value 1.")
166
+
167
+ self._user_context = (user_id, tenant_id)
168
+
169
+ return self._user_context
170
+
171
+ @staticmethod
172
+ def _find_existing_cci(control_id: int, cci_id: str) -> Optional[CCI]:
173
+ """
174
+ Find existing CCI by ID within a control.
175
+
176
+ :param int control_id: Security control ID
177
+ :param str cci_id: CCI identifier to search for
178
+ :return: Existing CCI instance or None
179
+ :rtype: Optional[CCI]
180
+ """
181
+ try:
182
+ existing_ccis: List[CCI] = CCI.get_all_by_parent(parent_id=control_id)
183
+ for existing in existing_ccis:
184
+ if existing.uuid == cci_id:
185
+ return existing
186
+ except Exception:
187
+ pass
188
+ return None
189
+
190
+ @staticmethod
191
+ def _create_cci_data(
192
+ cci_id: str, definition: str, user_id: Optional[str], tenant_id: int, current_time: str
193
+ ) -> Dict:
194
+ """
195
+ Create common CCI data structure.
196
+
197
+ :param str cci_id: CCI identifier
198
+ :param str definition: CCI definition
199
+ :param Optional[str] user_id: User ID
200
+ :param int tenant_id: Tenant ID
201
+ :param str current_time: Current timestamp string
202
+ :return: Dictionary with common CCI attributes
203
+ :rtype: Dict
204
+ """
205
+ return {
206
+ "name": cci_id,
207
+ "description": definition,
208
+ "controlType": "policy",
209
+ "publishDate": current_time,
210
+ "dateLastUpdated": current_time,
211
+ "lastUpdatedById": user_id,
212
+ "isPublic": True,
213
+ "tenantsId": tenant_id,
214
+ }
215
+
216
+ @staticmethod
217
+ def _update_existing_cci(existing_cci: CCI, cci_data: Dict) -> None:
218
+ """
219
+ Update an existing CCI with new data.
220
+
221
+ :param CCI existing_cci: CCI instance to update
222
+ :param Dict cci_data: Dictionary with CCI attributes
223
+ :rtype: None
224
+ """
225
+ for key, value in cci_data.items():
226
+ setattr(existing_cci, key, value)
227
+ existing_cci.create_or_update()
228
+
229
+ @staticmethod
230
+ def _create_new_cci(cci_id: str, cci_data: Dict, control_id: int, user_id: Optional[str], current_time: str) -> CCI:
231
+ """
232
+ Create a new CCI instance.
233
+
234
+ :param str cci_id: CCI identifier
235
+ :param Dict cci_data: Dictionary with common CCI attributes
236
+ :param int control_id: Security control ID
237
+ :param Optional[str] user_id: User ID
238
+ :param str current_time: Current timestamp string
239
+ :return: Created CCI instance
240
+ :rtype: CCI
241
+ """
242
+ new_cci = CCI(
243
+ uuid=cci_id,
244
+ securityControlId=control_id,
245
+ createdById=user_id,
246
+ dateCreated=current_time,
247
+ **cci_data,
248
+ )
249
+ new_cci.create()
250
+ return new_cci
251
+
252
+ def _process_cci_for_control(
253
+ self, control_id: int, cci_list: List[Dict], user_id: Optional[str], tenant_id: int
254
+ ) -> Tuple[int, int]:
255
+ """
256
+ Process all CCI items for a specific control.
257
+
258
+ :param int control_id: Security control ID
259
+ :param List[Dict] cci_list: List of CCI data dictionaries
260
+ :param Optional[str] user_id: User ID
261
+ :param int tenant_id: Tenant ID
262
+ :return: Tuple of (created_count, updated_count)
263
+ :rtype: Tuple[int, int]
264
+ """
265
+ created_count = 0
266
+ updated_count = 0
267
+ current_time = datetime.datetime.now().strftime(REGSCALE_DATE_FORMAT)
268
+
269
+ for cci in cci_list:
270
+ cci_id = cci["cci_id"]
271
+ definition = cci["definition"]
272
+
273
+ existing_cci = self._find_existing_cci(control_id, cci_id)
274
+ cci_data = self._create_cci_data(cci_id, definition, user_id, tenant_id, current_time)
275
+
276
+ if existing_cci:
277
+ self._update_existing_cci(existing_cci, cci_data)
278
+ updated_count += 1
279
+ else:
280
+ self._create_new_cci(cci_id, cci_data, control_id, user_id, current_time)
281
+ created_count += 1
282
+
283
+ return created_count, updated_count
284
+
285
+ def map_to_security_controls(self, catalog_id: int = 1) -> Dict[str, int]:
286
+ """
287
+ Map normalized CCI data to security controls in the database.
288
+
289
+ :param int catalog_id: ID of the catalog containing security controls (default: 1)
290
+ :return: Dictionary with operation statistics
291
+ :rtype: Dict[str, int]
292
+ """
293
+ if self.verbose:
294
+ click.echo("Mapping CCI data to security controls...")
295
+
296
+ catalog = self._get_catalog(catalog_id)
297
+ security_controls: List[SecurityControl] = SecurityControl.get_all_by_parent(parent_id=catalog.id)
298
+ control_map = {sc.controlId: sc.id for sc in security_controls}
299
+
300
+ user_id, tenant_id = self._get_user_context()
301
+
302
+ created_count = 0
303
+ updated_count = 0
304
+ skipped_count = 0
305
+
306
+ for main_control, cci_list in self.normalized_cci.items():
307
+ if main_control in control_map:
308
+ control_id = control_map[main_control]
309
+ control_created, control_updated = self._process_cci_for_control(
310
+ control_id, cci_list, user_id, tenant_id
311
+ )
312
+ created_count += control_created
313
+ updated_count += control_updated
314
+ else:
315
+ skipped_count += len(cci_list)
316
+ if self.verbose:
317
+ click.echo(f"Warning: Control not found for key: {main_control}", err=True)
318
+
319
+ return {
320
+ "created": created_count,
321
+ "updated": updated_count,
322
+ "skipped": skipped_count,
323
+ "total_processed": len(self.normalized_cci),
324
+ }
325
+
326
+ def get_normalized_cci(self) -> Dict[str, List[Dict]]:
327
+ """
328
+ Get the normalized CCI data.
329
+
330
+ :return: Dictionary of normalized CCI data
331
+ :rtype: Dict[str, List[Dict]]
332
+ """
333
+ return self.normalized_cci
334
+
335
+
336
+ def _load_xml_file(xml_file: str) -> ET.Element:
337
+ """
338
+ Load and parse XML file.
339
+
340
+ :param str xml_file: Path to XML file
341
+ :return: Root element of parsed XML
342
+ :rtype: ET.Element
343
+ :raises click.ClickException: If XML parsing fails
344
+ """
345
+ try:
346
+ click.echo(f"Loading XML file: {xml_file}")
347
+ tree = ET.parse(xml_file)
348
+ return tree.getroot()
349
+ except ET.ParseError as e:
350
+ click.echo(click.style(f"Failed to parse XML file: {e}", fg="red"), err=True)
351
+ error_and_exit(f"Failed to parse XML file: {e}")
352
+
353
+
354
+ def _display_verbose_output(normalized_data: Dict[str, List[Dict]]) -> None:
355
+ """
356
+ Display detailed normalized CCI data.
357
+
358
+ :param Dict[str, List[Dict]] normalized_data: Dictionary of normalized CCI data
359
+ :rtype: None
360
+ """
361
+ click.echo("\nNormalized CCI Data:")
362
+ for key, value in normalized_data.items():
363
+ click.echo(f" {key}: {len(value)} CCI items")
364
+ for cci in value:
365
+ definition_preview = cci["definition"][:100] + "..." if len(cci["definition"]) > 100 else cci["definition"]
366
+ click.echo(f" - {cci['cci_id']}: {definition_preview}")
367
+
368
+
369
+ def _display_results(stats: Dict[str, int]) -> None:
370
+ """
371
+ Display database operation results.
372
+
373
+ :param Dict[str, int] stats: Dictionary with operation statistics
374
+ :rtype: None
375
+ """
376
+ click.echo(
377
+ click.style(
378
+ f"\nDatabase operations completed:"
379
+ f"\n - Created: {stats['created']}"
380
+ f"\n - Updated: {stats['updated']}"
381
+ f"\n - Skipped: {stats['skipped']}"
382
+ f"\n - Total processed: {stats['total_processed']}",
383
+ fg="green",
384
+ )
385
+ )
386
+
387
+
388
+ def _process_cci_import(importer: CCIImporter, dry_run: bool, verbose: bool, catalog_id: int) -> None:
389
+ """
390
+ Process CCI import with optional database operations.
391
+
392
+ :param CCIImporter importer: CCIImporter instance
393
+ :param bool dry_run: Whether to skip database operations
394
+ :param bool verbose: Whether to display verbose output
395
+ :param int catalog_id: ID of the catalog containing security controls
396
+ :rtype: None
397
+ """
398
+ importer.parse_cci()
399
+ normalized_data = importer.get_normalized_cci()
400
+
401
+ click.echo(click.style(f"Successfully parsed {len(normalized_data)} normalized CCI entries", fg="green"))
402
+
403
+ if verbose:
404
+ _display_verbose_output(normalized_data)
405
+
406
+ if not dry_run:
407
+ stats = importer.map_to_security_controls(catalog_id)
408
+ _display_results(stats)
409
+ else:
410
+ click.echo(click.style("\nDRY RUN MODE: No database changes were made", fg="yellow"))
411
+
412
+
413
+ @click.command(name="cci_importer")
414
+ @click.option(
415
+ "--xml_file", "-f", type=click.Path(exists=True), default=None, required=False, help="Path to the CCI XML file."
416
+ )
417
+ @click.option("--dry-run", "-d", is_flag=True, help="Parse and display normalized data without saving to database")
418
+ @click.option("--verbose", "-v", is_flag=True, help="Display detailed output including all normalized CCI data")
419
+ @click.option(
420
+ "--nist-version", "-n", type=click.Choice(["4", "5"]), default="5", help="NIST 800-53 Revision version (default: 5)"
421
+ )
422
+ @click.option(
423
+ "--catalog-id", "-c", type=click.INT, default=1, help="ID of the catalog containing security controls (default: 1)"
424
+ )
425
+ def cci_importer(xml_file: str, dry_run: bool, verbose: bool, nist_version: str, catalog_id: int) -> None:
426
+ """Import CCI data from XML files and map to security controls.
427
+
428
+ If no XML file is specified, defaults to 'artifacts/U_CCI_List.xml' in the project directory.
429
+ """
430
+
431
+ try:
432
+ if not xml_file:
433
+ import importlib.resources as pkg_resources
434
+
435
+ xml_file = pkg_resources.path("regscale.models.integration_models", "CCI_List.xml")
436
+ root = _load_xml_file(xml_file)
437
+ importer = CCIImporter(root, version=nist_version, verbose=verbose)
438
+ _process_cci_import(importer, dry_run, verbose, catalog_id)
439
+
440
+ except click.ClickException:
441
+ raise
442
+ except Exception as e:
443
+ click.echo(click.style(f"Unexpected error: {e}", fg="red"), err=True)
444
+ raise click.ClickException(f"Unexpected error: {e}")