regscale-cli 6.24.0.1__py3-none-any.whl → 6.25.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 (30) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/api.py +1 -1
  3. regscale/core/app/application.py +5 -3
  4. regscale/core/app/internal/evidence.py +308 -202
  5. regscale/dev/code_gen.py +84 -3
  6. regscale/integrations/commercial/__init__.py +2 -0
  7. regscale/integrations/commercial/microsoft_defender/defender.py +326 -5
  8. regscale/integrations/commercial/microsoft_defender/defender_api.py +348 -14
  9. regscale/integrations/commercial/microsoft_defender/defender_constants.py +157 -0
  10. regscale/integrations/commercial/synqly/assets.py +99 -16
  11. regscale/integrations/commercial/synqly/query_builder.py +533 -0
  12. regscale/integrations/commercial/synqly/vulnerabilities.py +134 -14
  13. regscale/integrations/commercial/wizv2/compliance_report.py +22 -0
  14. regscale/integrations/compliance_integration.py +17 -0
  15. regscale/integrations/scanner_integration.py +16 -0
  16. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  17. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +12 -2
  18. regscale/models/integration_models/synqly_models/filter_parser.py +332 -0
  19. regscale/models/integration_models/synqly_models/synqly_model.py +47 -3
  20. regscale/models/regscale_models/compliance_settings.py +28 -0
  21. regscale/models/regscale_models/component.py +1 -0
  22. regscale/models/regscale_models/control_implementation.py +130 -1
  23. regscale/regscale.py +1 -1
  24. regscale/validation/record.py +23 -1
  25. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/METADATA +1 -1
  26. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/RECORD +30 -28
  27. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/LICENSE +0 -0
  28. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/WHEEL +0 -0
  29. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/entry_points.txt +0 -0
  30. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/top_level.txt +0 -0
@@ -99,11 +99,17 @@ class Vulnerabilities(SynqlyModel):
99
99
  """
100
100
  vuln_filter = self._handle_scan_date_options(regscale_ssp_id=regscale_ssp_id, **kwargs)
101
101
  self.logger.debug(f"Vulnerability filter: {vuln_filter}")
102
+
103
+ # Pop the filter from kwargs so it doesn't get passed to query_findings
104
+ asset_filter = kwargs.pop("filter", [])
105
+ if asset_filter:
106
+ self.logger.debug(f"Asset filter: {asset_filter}")
107
+
102
108
  self.logger.info(f"Fetching vulnerability data from {self.integration_name}...")
103
109
  findings = (
104
110
  self.fetch_integration_data(
105
111
  func=self.tenant.engine_client.vulnerabilities.query_findings,
106
- filter=vuln_filter,
112
+ filter=vuln_filter, # Only severity/date filters for findings
107
113
  **kwargs,
108
114
  )
109
115
  if self.can_fetch_vulns
@@ -111,7 +117,11 @@ class Vulnerabilities(SynqlyModel):
111
117
  )
112
118
  self.logger.info(f"Fetching asset data from {self.integration_name}...")
113
119
  assets = (
114
- self.fetch_integration_data(func=self.tenant.engine_client.vulnerabilities.query_assets, **kwargs)
120
+ self.fetch_integration_data(
121
+ func=self.tenant.engine_client.vulnerabilities.query_assets,
122
+ filter=asset_filter, # Field-based filters only for assets
123
+ **kwargs,
124
+ )
115
125
  if self.can_fetch_assets
116
126
  else []
117
127
  )
@@ -0,0 +1,332 @@
1
+ """
2
+ Centralized parser for extracting filter definitions from Synqly capabilities.
3
+ Used by both code generation and query builder.
4
+ """
5
+
6
+ import json
7
+ import re
8
+ from typing import Dict, List, Optional, Set, Tuple
9
+ import importlib.resources as pkg_resources
10
+
11
+ from regscale.models.integration_models.synqly_models.connector_types import ConnectorType
12
+
13
+
14
+ class FilterParser:
15
+ """Parser for Synqly filter definitions from capabilities.json"""
16
+
17
+ # Define which connectors support filtering
18
+ FILTERABLE_CONNECTORS: Set[str] = {ConnectorType.Assets.value, ConnectorType.Vulnerabilities.value}
19
+
20
+ def __init__(self, capabilities_data: Optional[List[dict]] = None):
21
+ """
22
+ Initialize the filter parser with provided or loaded capabilities.
23
+
24
+ :param Optional[List[dict]] capabilities_data: Pre-loaded capabilities data.
25
+ If None, will load from package resources.
26
+ """
27
+ if capabilities_data is None:
28
+ self.capabilities_data = self._load_capabilities()
29
+ else:
30
+ self.capabilities_data = capabilities_data
31
+ self.filter_mapping = self._build_filter_mapping()
32
+
33
+ def _load_capabilities(self) -> List[dict]:
34
+ """
35
+ Load capabilities.json from package resources.
36
+
37
+ :return: List of capability definitions
38
+ :rtype: List[dict]
39
+ """
40
+ try:
41
+ files = pkg_resources.files("regscale.models.integration_models.synqly_models")
42
+ capabilities_file = files / "capabilities.json"
43
+ with capabilities_file.open("r") as file:
44
+ data = json.load(file)
45
+ return data.get("result", [])
46
+ except Exception as e:
47
+ print(f"Error loading capabilities.json: {e}")
48
+ return []
49
+
50
+ def _build_filter_mapping(self) -> Dict[str, Dict[str, List[dict]]]:
51
+ """
52
+ Build comprehensive filter mapping for all providers.
53
+
54
+ Structure:
55
+ {
56
+ 'assets_armis_centrix': {
57
+ 'query_devices': [
58
+ {
59
+ 'name': 'device.ip',
60
+ 'type': 'string',
61
+ 'operators': ['eq', 'ne', 'in', 'not_in'],
62
+ 'values': [] # For enum types
63
+ },
64
+ ...
65
+ ]
66
+ },
67
+ 'vulnerabilities_qualys': {
68
+ 'query_findings': [...],
69
+ 'query_assets': [...]
70
+ },
71
+ ...
72
+ }
73
+
74
+ :return: Mapping of provider IDs to their operations and filters
75
+ :rtype: Dict[str, Dict[str, List[dict]]]
76
+ """
77
+ filter_mapping = {}
78
+
79
+ for provider in self.capabilities_data:
80
+ provider_id = provider.get("id", "")
81
+ connector_type = provider.get("connector", "")
82
+
83
+ # Only process filterable connector types
84
+ if connector_type not in self.FILTERABLE_CONNECTORS:
85
+ continue
86
+
87
+ # Skip mock providers if desired (they end with _mock)
88
+ # if provider_id.endswith('_mock'):
89
+ # continue
90
+
91
+ operations = provider.get("operations", [])
92
+ for operation in operations:
93
+ # Only process supported operations with filters
94
+ if not operation.get("supported", False):
95
+ continue
96
+
97
+ filters = operation.get("filters", [])
98
+ if filters:
99
+ if provider_id not in filter_mapping:
100
+ filter_mapping[provider_id] = {}
101
+
102
+ operation_name = operation.get("name", "")
103
+ filter_mapping[provider_id][operation_name] = filters
104
+
105
+ return filter_mapping
106
+
107
+ def get_filters_for_provider(self, provider_id: str, operation: Optional[str] = None) -> List[dict]:
108
+ """
109
+ Get filters for a specific provider and optionally a specific operation.
110
+
111
+ :param str provider_id: Provider ID (e.g., 'assets_armis_centrix')
112
+ :param Optional[str] operation: Operation name (e.g., 'query_devices')
113
+ :return: List of filter definitions
114
+ :rtype: List[dict]
115
+ """
116
+ provider_filters = self.filter_mapping.get(provider_id, {})
117
+
118
+ if operation:
119
+ return provider_filters.get(operation, [])
120
+
121
+ # Return all filters for all operations if no specific operation
122
+ all_filters = []
123
+ seen_fields = set() # Avoid duplicates
124
+
125
+ for op_filters in provider_filters.values():
126
+ for filter_def in op_filters:
127
+ field_name = filter_def.get("name", "")
128
+ if field_name not in seen_fields:
129
+ all_filters.append(filter_def)
130
+ seen_fields.add(field_name)
131
+
132
+ return all_filters
133
+
134
+ def get_providers_with_filters(self, connector_type: str) -> List[str]:
135
+ """
136
+ Get list of providers that support filtering for a connector type.
137
+
138
+ :param str connector_type: Connector type (e.g., 'assets', 'vulnerabilities')
139
+ :return: List of provider IDs that have filters
140
+ :rtype: List[str]
141
+ """
142
+ providers = []
143
+
144
+ for provider_id, operations in self.filter_mapping.items():
145
+ # Check if provider matches connector type and has filters
146
+ if provider_id.startswith(f"{connector_type}_") and operations:
147
+ providers.append(provider_id)
148
+
149
+ # Sort for consistent ordering
150
+ return sorted(providers)
151
+
152
+ def has_filters(self, provider_id: str) -> bool:
153
+ """
154
+ Check if a provider has any filters defined.
155
+
156
+ :param str provider_id: Provider ID to check
157
+ :return: True if provider has filters
158
+ :rtype: bool
159
+ """
160
+ return provider_id in self.filter_mapping and bool(self.filter_mapping[provider_id])
161
+
162
+ @staticmethod
163
+ def format_filter_string(field: str, operator: str, value: str) -> str:
164
+ """
165
+ Convert user input to Synqly filter format.
166
+
167
+ :param str field: Field name (e.g., 'device.ip')
168
+ :param str operator: Operator (e.g., 'eq', 'gte')
169
+ :param str value: Filter value
170
+ :return: Formatted filter string
171
+ :rtype: str
172
+
173
+ Example:
174
+ format_filter_string('device.ip', 'eq', '192.168.1.1')
175
+ Returns: 'device.ip[eq]192.168.1.1'
176
+ """
177
+ return f"{field}[{operator}]{value}"
178
+
179
+ @staticmethod
180
+ def parse_filter_string(filter_string: str) -> Optional[Tuple[str, str, str]]:
181
+ """
182
+ Parse a filter string into its components.
183
+
184
+ :param str filter_string: Filter in format 'field[operator]value'
185
+ :return: Tuple of (field, operator, value) or None if invalid
186
+ :rtype: Optional[Tuple[str, str, str]]
187
+ """
188
+ match = re.match(r"^([a-z._]+)\[([a-z_]+)\](.+)$", filter_string, re.IGNORECASE)
189
+ if match:
190
+ return match.groups()
191
+ return None
192
+
193
+ def validate_filter(self, provider_id: str, filter_string: str) -> Tuple[bool, str]:
194
+ """
195
+ Validate a filter string against provider capabilities.
196
+
197
+ :param str provider_id: Provider ID (e.g., 'assets_armis_centrix')
198
+ :param str filter_string: Filter in format 'field[operator]value'
199
+ :return: Tuple of (is_valid, error_message)
200
+ :rtype: Tuple[bool, str]
201
+ """
202
+ # Parse the filter string
203
+ parsed = self.parse_filter_string(filter_string)
204
+ if not parsed:
205
+ return False, f"Invalid filter format: {filter_string}. Expected format: field[operator]value"
206
+
207
+ field, operator, value = parsed
208
+
209
+ # Get all filters for this provider
210
+ provider_filters = self.get_filters_for_provider(provider_id)
211
+
212
+ if not provider_filters:
213
+ return False, f"Provider '{provider_id}' does not support filtering"
214
+
215
+ # Check if field exists
216
+ field_filter = None
217
+ for f in provider_filters:
218
+ if f.get("name") == field:
219
+ field_filter = f
220
+ break
221
+
222
+ if not field_filter:
223
+ available_fields = [f.get("name", "") for f in provider_filters]
224
+ return (
225
+ False,
226
+ f"Field '{field}' not supported by {provider_id}. Available fields: {', '.join(available_fields)}",
227
+ )
228
+
229
+ # Check if operator is valid for this field
230
+ valid_operators = field_filter.get("operators", [])
231
+ if operator not in valid_operators:
232
+ return (
233
+ False,
234
+ f"Operator '{operator}' not valid for field '{field}'. Valid operators: {', '.join(valid_operators)}",
235
+ )
236
+
237
+ # Optionally validate value type and format
238
+ field_type = field_filter.get("type", "string")
239
+
240
+ # Handle comma-separated values for 'in' and 'not_in' operators
241
+ if operator in ["in", "not_in"]:
242
+ values_to_check = [v.strip() for v in value.split(",")]
243
+ else:
244
+ values_to_check = [value]
245
+
246
+ for val in values_to_check:
247
+ if field_type == "number":
248
+ try:
249
+ float(val)
250
+ except ValueError:
251
+ return False, f"Value '{val}' is not a valid number for field '{field}'"
252
+ elif field_type == "enum":
253
+ valid_values = field_filter.get("values", [])
254
+ if valid_values and val not in valid_values:
255
+ return (
256
+ False,
257
+ f"Value '{val}' not valid for field '{field}'. Valid values: {', '.join(valid_values)}",
258
+ )
259
+
260
+ return True, ""
261
+
262
+ def get_operator_display_name(self, operator: str) -> str:
263
+ """
264
+ Get human-friendly display name for an operator.
265
+
266
+ :param str operator: Operator code
267
+ :return: Display name
268
+ :rtype: str
269
+ """
270
+ operator_map = {
271
+ "eq": "equals",
272
+ "ne": "not equals",
273
+ "in": "in list",
274
+ "not_in": "not in list",
275
+ "like": "matches pattern",
276
+ "not_like": "does not match pattern",
277
+ "gt": "greater than",
278
+ "gte": "greater than or equal to",
279
+ "lt": "less than",
280
+ "lte": "less than or equal to",
281
+ }
282
+ return operator_map.get(operator, operator)
283
+
284
+ def get_field_display_name(self, field: str) -> str:
285
+ """
286
+ Convert field name to human-friendly display name.
287
+
288
+ :param str field: Field name (e.g., 'device.hw_info.serial_number')
289
+ :return: Display name (e.g., 'Device Hardware Info Serial Number')
290
+ :rtype: str
291
+ """
292
+ # Replace dots and underscores with spaces, then title case
293
+ display = field.replace(".", " ").replace("_", " ").title()
294
+ return display
295
+
296
+ def get_connector_operations(self, connector_type: str) -> Dict[str, List[str]]:
297
+ """
298
+ Get all operations that support filtering for a connector type.
299
+
300
+ :param str connector_type: Connector type (e.g., 'assets')
301
+ :return: Dict mapping provider IDs to their filterable operations
302
+ :rtype: Dict[str, List[str]]
303
+ """
304
+ operations_map = {}
305
+
306
+ for provider_id, operations in self.filter_mapping.items():
307
+ if provider_id.startswith(f"{connector_type}_"):
308
+ operations_map[provider_id] = list(operations.keys())
309
+
310
+ return operations_map
311
+
312
+ def get_stats(self) -> dict:
313
+ """
314
+ Get statistics about loaded filters.
315
+
316
+ :return: Dictionary with filter statistics
317
+ :rtype: dict
318
+ """
319
+ stats = {
320
+ "total_providers": len(self.capabilities_data),
321
+ "providers_with_filters": len(self.filter_mapping),
322
+ "total_filters": 0,
323
+ "by_connector": {},
324
+ }
325
+
326
+ for connector in self.FILTERABLE_CONNECTORS:
327
+ providers = self.get_providers_with_filters(connector)
328
+ filter_count = sum(len(self.get_filters_for_provider(p)) for p in providers)
329
+ stats["by_connector"][connector] = {"providers": len(providers), "filters": filter_count}
330
+ stats["total_filters"] += filter_count
331
+
332
+ return stats
@@ -20,6 +20,7 @@ from regscale.core.app.api import Api
20
20
  from regscale.core.app.application import Application
21
21
  from regscale.core.app.utils.app_utils import create_progress_object, error_and_exit
22
22
  from regscale.models.integration_models.synqly_models.connector_types import ConnectorType
23
+ from regscale.models.integration_models.synqly_models.filter_parser import FilterParser
23
24
  from regscale.models.integration_models.synqly_models.ocsf_mapper import Mapper
24
25
  from regscale.models.integration_models.synqly_models.param import Param
25
26
  from regscale.models.integration_models.synqly_models.tenants import Tenant
@@ -37,7 +38,7 @@ class SynqlyModel(BaseModel, ABC):
37
38
  client: Optional[Any] = None
38
39
  connectors: dict = Field(default_factory=dict)
39
40
  # defined using the openApi spec on 7/16/2024, this is updated via _get_integrations_and_secrets()
40
- connector_types: set = Field(default_factory=lambda: set([connector.__str__() for connector in ConnectorType]))
41
+ connector_types: set = Field(default_factory=lambda: {connector.__str__() for connector in ConnectorType})
41
42
  terminated: Optional[bool] = False
42
43
  app: Application = Field(default_factory=Application, alias="app")
43
44
  api: Api = Field(default_factory=Api, alias="api")
@@ -60,6 +61,7 @@ class SynqlyModel(BaseModel, ABC):
60
61
  created_regscale_objects: list = Field(default_factory=list)
61
62
  updated_regscale_objects: list = Field(default_factory=list)
62
63
  regscale_objects_to_update: list = Field(default_factory=list)
64
+ filter_parser: Optional[FilterParser] = None
63
65
 
64
66
  def __init__(self: S, connector_type: Optional[str] = None, integration: Optional[str] = None, **kwargs):
65
67
  try:
@@ -278,6 +280,8 @@ class SynqlyModel(BaseModel, ABC):
278
280
  :rtype: dict
279
281
  """
280
282
  raw_data = self._load_from_package()
283
+ # Initialize FilterParser with the loaded capabilities data
284
+ self.filter_parser = FilterParser(capabilities_data=raw_data)
281
285
  return self._parse_api_spec_data(raw_data, return_params)
282
286
 
283
287
  def _parse_api_spec_data(self, data: dict, return_params: bool = False) -> dict:
@@ -441,12 +445,12 @@ class SynqlyModel(BaseModel, ABC):
441
445
 
442
446
  operations = schema.get("operations", [])
443
447
  capabilities = [item["name"] for item in operations if item.get("supported")]
444
- capabilities_params = list(
448
+ capabilities_params = [
445
449
  field
446
450
  for item in operations
447
451
  if item.get("supported") and "required_fields" in item.keys()
448
452
  for field in item.get("required_fields", [])
449
- )
453
+ ]
450
454
  if self.integration.lower() in key.lower():
451
455
  self.capabilities = capabilities
452
456
  schema = schema["provider_config"]
@@ -636,6 +640,42 @@ class SynqlyModel(BaseModel, ABC):
636
640
  """
637
641
  pass
638
642
 
643
+ def validate_filters(self, filters: Union[tuple, list, str]) -> list[str]:
644
+ """
645
+ Validate filter strings against provider capabilities.
646
+
647
+ :param Union[tuple, list, str] filters: Filter(s) to validate
648
+ :return: Validated filter list
649
+ :rtype: list[str]
650
+ :raises: SystemExit if validation fails
651
+ """
652
+ if not self.filter_parser:
653
+ self.logger.warning("FilterParser not available for filter validation")
654
+ if isinstance(filters, list):
655
+ return filters
656
+ elif filters:
657
+ return [filters]
658
+ else:
659
+ return []
660
+
661
+ provider_id = f"{self._connector_type}_{self.integration}"
662
+ validated_filters = []
663
+
664
+ # Normalize to list for processing
665
+ if isinstance(filters, str):
666
+ filters = [filters]
667
+ elif filters is None:
668
+ return []
669
+
670
+ for filter_string in filters:
671
+ is_valid, error_message = self.filter_parser.validate_filter(provider_id, filter_string)
672
+ if not is_valid:
673
+ error_and_exit(f"Filter validation failed: {error_message}")
674
+ validated_filters.append(filter_string)
675
+ self.logger.debug(f"Filter '{filter_string}' validated successfully")
676
+
677
+ return validated_filters
678
+
639
679
  def fetch_integration_data(
640
680
  self, func: Callable, **kwargs
641
681
  ) -> list[Union["InventoryAsset", "SecurityFinding", "Ticket"]]:
@@ -648,6 +688,10 @@ class SynqlyModel(BaseModel, ABC):
648
688
  """
649
689
  query_filter = kwargs.get("filter")
650
690
  limit = kwargs.get("limit", 200)
691
+
692
+ # Validate filters if provided
693
+ if query_filter:
694
+ query_filter = self.validate_filters(query_filter)
651
695
  integration_data: list = []
652
696
  fetch_res = func(
653
697
  filter=query_filter,
@@ -110,3 +110,31 @@ class ComplianceSettings(RegScaleModel):
110
110
  :rtype: List[str]
111
111
  """
112
112
  return self.__class__.get_labels(self.id, setting_field)
113
+
114
+ @classmethod
115
+ def get_settings_list(cls) -> List[dict]:
116
+ """
117
+ Get all compliance settings list items from settingsList endpoint.
118
+
119
+ :return: A list of compliance settings list items
120
+ :rtype: List[dict]
121
+ """
122
+ response = cls._get_api_handler().get(endpoint="/api/compliance/settingsList")
123
+ if response and response.ok:
124
+ return response.json()
125
+ return []
126
+
127
+ @classmethod
128
+ def get_default_responsibility_for_compliance_setting(cls, compliance_setting_id: int) -> Optional[str]:
129
+ """
130
+ Get the default responsibility value for a specific compliance setting.
131
+
132
+ :param int compliance_setting_id: The compliance setting ID
133
+ :return: The default responsibility value or None if not found
134
+ :rtype: Optional[str]
135
+ """
136
+ settings_list = cls.get_settings_list()
137
+ for setting in settings_list:
138
+ if setting.get("complianceSettingId") == compliance_setting_id and setting.get("isDefault", False):
139
+ return setting.get("statusName")
140
+ return None
@@ -76,6 +76,7 @@ class Component(RegScaleModel):
76
76
  externalId: Optional[str] = None
77
77
  isPublic: bool = True
78
78
  riskCategorization: Optional[str] = None
79
+ complianceSettingsId: Optional[int] = None
79
80
 
80
81
  @staticmethod
81
82
  def _get_additional_endpoints() -> ConfigDict:
@@ -3,6 +3,7 @@
3
3
  """Model for a RegScale Security Control Implementation"""
4
4
  # standard python imports
5
5
  import logging
6
+ from functools import lru_cache
6
7
  from enum import Enum
7
8
  from typing import Any, Callable, Dict, List, Optional, Union
8
9
  from urllib.parse import urljoin
@@ -104,7 +105,9 @@ class ControlImplementation(RegScaleModel):
104
105
  qiVendorCompliance: Optional[str] = None
105
106
  qiIssues: Optional[str] = None
106
107
  qiOverall: Optional[str] = None
107
- responsibility: Optional[str] = None
108
+ responsibility: str = Field(
109
+ default_factory=lambda: ControlImplementation.get_default_responsibility()
110
+ ) # Required field - Control Origination
108
111
  inheritedControlId: Optional[int] = None
109
112
  inheritedRequirementId: Optional[int] = None
110
113
  inheritedSecurityPlanId: Optional[int] = None
@@ -157,6 +160,17 @@ class ControlImplementation(RegScaleModel):
157
160
  if self.controlOwnersIds is None and self.controlOwnerId:
158
161
  self.controlOwnersIds = [self.controlOwnerId]
159
162
 
163
+ # Set intelligent default responsibility if not explicitly set and we have parent info
164
+ if (
165
+ self.responsibility == self.get_default_responsibility()
166
+ and self.parentId
167
+ and self.parentModule == "securityplans"
168
+ ):
169
+ # Try to get a more specific default based on the actual security plan's compliance settings
170
+ better_default = self.get_default_responsibility(parent_id=self.parentId)
171
+ if better_default != self.responsibility:
172
+ self.responsibility = better_default
173
+
160
174
  def __setattr__(self, name: str, value: Any) -> None:
161
175
  """
162
176
  Override __setattr__ to update status_lst when status changes and handle backwards compatibility.
@@ -175,6 +189,121 @@ class ControlImplementation(RegScaleModel):
175
189
  ):
176
190
  super().__setattr__("controlOwnersIds", [value])
177
191
 
192
+ @classmethod
193
+ @lru_cache(maxsize=256)
194
+ def get_default_responsibility(
195
+ cls, parent_id: Optional[int] = None, compliance_setting_id: Optional[int] = None
196
+ ) -> str:
197
+ """
198
+ Get default responsibility (control origination) based on compliance settings.
199
+
200
+ Cached for high-performance bulk operations.
201
+
202
+ :param Optional[int] parent_id: The parent security plan ID to get compliance settings from
203
+ :param Optional[int] compliance_setting_id: Specific compliance setting ID override
204
+ :return: Default responsibility string
205
+ :rtype: str
206
+ """
207
+ actual_compliance_setting_id = compliance_setting_id or cls._get_compliance_setting_id_from_parent(parent_id)
208
+
209
+ if actual_compliance_setting_id:
210
+ responsibility = cls._get_responsibility_from_compliance_settings(actual_compliance_setting_id)
211
+ if responsibility:
212
+ return responsibility
213
+
214
+ return cls._get_fallback_responsibility(actual_compliance_setting_id)
215
+
216
+ @classmethod
217
+ @lru_cache(maxsize=128)
218
+ def _get_compliance_setting_id_from_parent(cls, parent_id: Optional[int]) -> Optional[int]:
219
+ """
220
+ Get compliance setting ID from parent security plan.
221
+
222
+ Cached to avoid repeated API calls for the same security plan.
223
+ """
224
+ if not parent_id:
225
+ return None
226
+
227
+ try:
228
+ from regscale.models.regscale_models.security_plan import SecurityPlan
229
+
230
+ security_plan = SecurityPlan.get_object(parent_id)
231
+ return security_plan.complianceSettingsId if security_plan else None
232
+ except Exception:
233
+ return None
234
+
235
+ @classmethod
236
+ @lru_cache(maxsize=32)
237
+ def _get_responsibility_from_compliance_settings(cls, compliance_setting_id: int) -> Optional[str]:
238
+ """
239
+ Get default responsibility from compliance settings API using settingsList endpoint.
240
+
241
+ Cached to avoid repeated API calls for the same compliance setting.
242
+ """
243
+ try:
244
+ from regscale.models.regscale_models.compliance_settings import ComplianceSettings
245
+
246
+ return ComplianceSettings.get_default_responsibility_for_compliance_setting(compliance_setting_id)
247
+ except Exception:
248
+ pass
249
+
250
+ return None
251
+
252
+ @classmethod
253
+ def _get_fallback_responsibility(cls, compliance_setting_id: Optional[int] = None) -> str:
254
+ """
255
+ Get intelligent fallback responsibility using framework-specific defaults.
256
+
257
+ :param Optional[int] compliance_setting_id: Compliance setting ID to determine framework type
258
+ :return: Fallback responsibility string
259
+ :rtype: str
260
+ """
261
+ if compliance_setting_id:
262
+ return cls._get_framework_default_responsibility(compliance_setting_id)
263
+
264
+ # Ultimate fallback for unknown compliance settings
265
+ return ControlImplementationOrigin.SERVICE_PROVIDER_CORPORATE.value
266
+
267
+ @classmethod
268
+ def _get_framework_default_responsibility(cls, compliance_setting_id: int) -> str:
269
+ """
270
+ Get default responsibility for a specific compliance framework.
271
+
272
+ :param int compliance_setting_id: The compliance setting ID (1=RegScale, 2=FedRAMP, 3=PCI, 4=DoD, 5=CMMC)
273
+ :return: Default responsibility string
274
+ :rtype: str
275
+ """
276
+ try:
277
+ from regscale.models.regscale_models.compliance_settings import ComplianceSettings
278
+
279
+ default_value = ComplianceSettings.get_default_responsibility_for_compliance_setting(compliance_setting_id)
280
+ if default_value:
281
+ return default_value
282
+ except Exception:
283
+ pass
284
+
285
+ # Framework-specific fallbacks if API fails
286
+ fallback_map = {
287
+ 1: "Provider", # RegScale Default
288
+ 2: ImplementationControlOrigin.SERVICE_PROVIDER_CORPORATE.value, # FedRAMP
289
+ 3: ImplementationControlOrigin.SERVICE_PROVIDER_CORPORATE.value, # PCI
290
+ 4: "System-Specific", # DoD
291
+ 5: "Provider", # CMMC
292
+ }
293
+ return fallback_map.get(compliance_setting_id, "Service Provider Corporate")
294
+
295
+ @classmethod
296
+ def clear_responsibility_cache(cls) -> None:
297
+ """
298
+ Clear the responsibility lookup cache.
299
+
300
+ Call this method when compliance settings have been updated to ensure
301
+ fresh data is retrieved from the API.
302
+ """
303
+ cls.get_default_responsibility.cache_clear()
304
+ cls._get_compliance_setting_id_from_parent.cache_clear()
305
+ cls._get_responsibility_from_compliance_settings.cache_clear()
306
+
178
307
  @classmethod
179
308
  def _get_additional_endpoints(cls) -> ConfigDict:
180
309
  """
regscale/regscale.py CHANGED
@@ -293,7 +293,7 @@ def banner():
293
293
  \t[#05d1b7].clicli, [#15cfec].;loooool'
294
294
  \t[#05d1b7].clicli, [#18a8e9].:oolloc.
295
295
  \t[#05d1b7].clicli, [#ef7f2e].,cli,. [#18a8e9].clllll,
296
- \t[#05d1b7].clicli. [#ef7f2e].,oxxxxd; [#158fd0].:lllll;
296
+ \t[#05d1b7].clicli. [#ef7f2e].,oxxxxd; [#18a8e9].:lllll;
297
297
  \t[#05d1b7] ..cli. [#f68d1f]';cdxxxxxo, [#18a8e9].cllllc,
298
298
  \t [#f68d1f].:odddddddc. [#1b97d5] .;ccccc:.
299
299
  \t[#ffc42a] ..'. [#f68d1f].;ldddddddl' [#0c8cd7].':ccccc:.