regscale-cli 6.25.0.1__py3-none-any.whl → 6.26.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 (84) hide show
  1. regscale/_version.py +1 -1
  2. regscale/airflow/hierarchy.py +2 -2
  3. regscale/core/app/application.py +18 -3
  4. regscale/core/app/internal/login.py +0 -1
  5. regscale/core/app/utils/catalog_utils/common.py +1 -1
  6. regscale/integrations/commercial/sicura/api.py +14 -13
  7. regscale/integrations/commercial/sicura/commands.py +8 -2
  8. regscale/integrations/commercial/sicura/scanner.py +49 -39
  9. regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
  10. regscale/integrations/commercial/synqly/assets.py +17 -0
  11. regscale/integrations/commercial/wizv2/click.py +26 -26
  12. regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
  13. regscale/integrations/commercial/wizv2/constants.py +20 -71
  14. regscale/integrations/commercial/wizv2/scanner.py +3 -3
  15. regscale/integrations/compliance_integration.py +67 -2
  16. regscale/integrations/control_matcher.py +358 -0
  17. regscale/integrations/due_date_handler.py +118 -6
  18. regscale/integrations/milestone_manager.py +291 -0
  19. regscale/integrations/public/__init__.py +1 -0
  20. regscale/integrations/public/cci_importer.py +37 -38
  21. regscale/integrations/public/fedramp/click.py +60 -2
  22. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  23. regscale/integrations/scanner_integration.py +199 -130
  24. regscale/models/integration_models/cisa_kev_data.json +199 -4
  25. regscale/models/integration_models/nexpose.py +36 -10
  26. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  27. regscale/models/locking.py +12 -8
  28. regscale/models/platform.py +1 -2
  29. regscale/models/regscale_models/control_implementation.py +46 -21
  30. regscale/models/regscale_models/issue.py +256 -94
  31. regscale/models/regscale_models/milestone.py +1 -1
  32. regscale/models/regscale_models/regscale_model.py +6 -1
  33. regscale/templates/__init__.py +0 -0
  34. regscale/utils/threading/threadhandler.py +20 -15
  35. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
  36. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +84 -37
  37. tests/regscale/integrations/commercial/__init__.py +0 -0
  38. tests/regscale/integrations/commercial/conftest.py +28 -0
  39. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  40. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  41. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  42. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  43. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  44. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  45. tests/regscale/integrations/commercial/test_burp.py +48 -0
  46. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  47. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  48. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  49. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  50. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  51. tests/regscale/integrations/commercial/test_jira.py +1814 -0
  52. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  53. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  54. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  55. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  56. tests/regscale/integrations/commercial/test_snow.py +423 -0
  57. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  58. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  59. tests/regscale/integrations/commercial/test_stig.py +33 -0
  60. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  61. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  62. tests/regscale/integrations/commercial/test_wiz.py +1469 -0
  63. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  64. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  65. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  66. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  67. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  68. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
  69. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  70. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  71. tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
  72. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  73. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
  74. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
  75. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  76. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  77. tests/regscale/integrations/test_control_matcher.py +1314 -0
  78. tests/regscale/integrations/test_control_matching.py +155 -0
  79. tests/regscale/integrations/test_milestone_manager.py +408 -0
  80. tests/regscale/models/test_issue.py +378 -1
  81. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
  82. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
  83. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
  84. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,7 @@ from collections import defaultdict
13
13
  from typing import Dict, List, Optional, Any, Iterator
14
14
 
15
15
  from regscale.core.app.utils.app_utils import get_current_datetime, regscale_string_to_datetime
16
+ from regscale.integrations.control_matcher import ControlMatcher
16
17
  from regscale.integrations.scanner_integration import (
17
18
  ScannerIntegration,
18
19
  IntegrationAsset,
@@ -166,6 +167,9 @@ class ComplianceIntegration(ScannerIntegration, ABC):
166
167
  self._compliance_settings = None
167
168
  self._security_plan = None
168
169
 
170
+ # Initialize control matcher for robust control ID matching
171
+ self._control_matcher = ControlMatcher()
172
+
169
173
  def is_poam(self, finding: IntegrationFinding) -> bool: # type: ignore[override]
170
174
  """
171
175
  Determines if an issue should be considered a POAM for compliance integrations.
@@ -1245,13 +1249,22 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1245
1249
  """
1246
1250
  Find matching implementation and security control for a control ID.
1247
1251
 
1252
+ Uses ControlMatcher for robust control ID matching with leading zero normalization.
1253
+
1248
1254
  :param str control_id: Control identifier to match
1249
1255
  :param List[ControlImplementation] implementations: Available implementations
1250
1256
  :return: Tuple of matching implementation and security control, or (None, None)
1251
1257
  :rtype: tuple[Optional[ControlImplementation], Optional[SecurityControl]]
1252
1258
  """
1259
+ # Generate all variations of the search control ID for matching
1260
+ search_variations = self._control_matcher._get_control_id_variations(control_id)
1261
+ if not search_variations:
1262
+ logger.debug(f"Could not generate control ID variations for: {control_id}")
1263
+ return None, None
1264
+
1253
1265
  matching_implementation = None
1254
1266
  matching_security_control = None
1267
+
1255
1268
  for implementation in implementations:
1256
1269
  try:
1257
1270
  security_control = SecurityControl.get_object(object_id=implementation.controlID)
@@ -1264,10 +1277,16 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1264
1277
  if not security_control_id:
1265
1278
  logger.debug(f"Security control {security_control.id} has no controlId")
1266
1279
  continue
1280
+
1281
+ # Get variations of the security control ID
1282
+ control_variations = self._control_matcher._get_control_id_variations(security_control_id)
1283
+
1267
1284
  logger.debug(
1268
- f"Comparing extracted '{control_id}' with RegScale control '{security_control_id}' (impl: {implementation.id})"
1285
+ f"Comparing control '{control_id}' variations {list(search_variations)[:3]} with RegScale control '{security_control_id}' variations {list(control_variations)[:3]} (impl: {implementation.id})"
1269
1286
  )
1270
- if self._control_ids_match(control_id, security_control_id):
1287
+
1288
+ # Check if any variation matches (set intersection)
1289
+ if search_variations & control_variations:
1271
1290
  matching_implementation = implementation
1272
1291
  matching_security_control = security_control
1273
1292
  logger.info(
@@ -1364,12 +1383,19 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1364
1383
  component_title = f"Control {sec_control.controlId}"
1365
1384
  component = self.components_by_title.get(component_title) if hasattr(self, "components_by_title") else None
1366
1385
  if not component:
1386
+ # Get complianceSettingsId from the security plan
1387
+ security_plan = self._get_security_plan()
1388
+ compliance_settings_id = getattr(security_plan, "complianceSettingsId", None) if security_plan else None
1389
+ if not compliance_settings_id:
1390
+ compliance_setting = self._get_compliance_settings()
1391
+ compliance_settings_id = getattr(compliance_setting, "id", None) if compliance_setting else None
1367
1392
  component = regscale_models.Component(
1368
1393
  title=component_title,
1369
1394
  componentType=regscale_models.ComponentType.Hardware,
1370
1395
  securityPlansId=self.plan_id,
1371
1396
  description=component_title,
1372
1397
  componentOwnerId=self.get_assessor_id(),
1398
+ complianceSettingsId=compliance_settings_id,
1373
1399
  ).get_or_create()
1374
1400
  regscale_models.ComponentMapping(
1375
1401
  componentId=component.id,
@@ -2050,6 +2076,8 @@ class ComplianceIntegration(ScannerIntegration, ABC):
2050
2076
  """
2051
2077
  Create or update an issue from a finding, using cache to prevent duplicates.
2052
2078
 
2079
+ Properly handles milestone creation for compliance integrations.
2080
+
2053
2081
  :param str title: Issue title
2054
2082
  :param IntegrationFinding finding: The finding to create issue from
2055
2083
  :return: Created or updated issue
@@ -2068,6 +2096,9 @@ class ComplianceIntegration(ScannerIntegration, ABC):
2068
2096
  f"Found existing issue {existing_issue.id} (other_identifier: '{existing_issue.otherIdentifier}') for lookup external_id '{external_id}', updating instead of creating"
2069
2097
  )
2070
2098
 
2099
+ # Store original status for milestone comparison
2100
+ original_status = existing_issue.status
2101
+
2071
2102
  # Update existing issue with new finding data
2072
2103
  existing_issue.title = title
2073
2104
  existing_issue.description = finding.description
@@ -2095,8 +2126,42 @@ class ComplianceIntegration(ScannerIntegration, ABC):
2095
2126
  existing_issue.orgId = self.determine_issue_organization_id(existing_issue.issueOwnerId)
2096
2127
  existing_issue.save()
2097
2128
 
2129
+ # Create milestone if status changed
2130
+ # Reconstruct original issue state for comparison
2131
+ original_issue = regscale_models.Issue()
2132
+ original_issue.status = original_status
2133
+ self._create_milestones_for_updated_issue(existing_issue, finding, original_issue)
2134
+
2098
2135
  return existing_issue
2099
2136
  else:
2100
2137
  # No existing issue found, create new one using parent method
2101
2138
  logger.debug(f"No existing issue found for external_id {external_id}, creating new issue")
2102
2139
  return super().create_or_update_issue_from_finding(title, finding)
2140
+
2141
+ def _create_milestones_for_updated_issue(
2142
+ self,
2143
+ issue: regscale_models.Issue,
2144
+ finding: IntegrationFinding,
2145
+ original_issue: regscale_models.Issue,
2146
+ ) -> None:
2147
+ """
2148
+ Create milestones for an updated issue in compliance integration.
2149
+
2150
+ This method handles both status transition milestones and backfilling of missing
2151
+ creation milestones for existing issues.
2152
+
2153
+ :param regscale_models.Issue issue: The updated issue
2154
+ :param IntegrationFinding finding: The finding data
2155
+ :param regscale_models.Issue original_issue: Original state for comparison
2156
+ """
2157
+ milestone_manager = self.get_milestone_manager()
2158
+
2159
+ # First, ensure the issue has a creation milestone (backfill if missing)
2160
+ milestone_manager.ensure_creation_milestone_exists(issue=issue, finding=finding)
2161
+
2162
+ # Then, handle status transition milestones
2163
+ milestone_manager.create_milestones_for_issue(
2164
+ issue=issue,
2165
+ finding=finding,
2166
+ existing_issue=original_issue,
2167
+ )
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Control Matcher - A utility class for identifying and matching control implementations
5
+ across different RegScale entities based on control ID strings.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from typing import Dict, List, Optional, Tuple, Union
11
+
12
+ from regscale.core.app.api import Api
13
+ from regscale.core.app.application import Application
14
+ from regscale.models.regscale_models.control_implementation import ControlImplementation
15
+ from regscale.models.regscale_models.security_control import SecurityControl
16
+
17
+ logger = logging.getLogger("regscale")
18
+
19
+
20
+ class ControlMatcher:
21
+ """
22
+ A class to identify control IDs and match them across different RegScale entities.
23
+
24
+ This class provides control matching capabilities:
25
+ - Parse control ID strings to extract NIST control identifiers
26
+ - Match controls from catalogs to security plans
27
+ - Find control implementations based on control IDs
28
+ - Support multiple control ID formats (e.g., AC-1, AC-1(1), AC-1.1)
29
+
30
+ Note: This class is focused on finding and matching existing controls only.
31
+ Control creation/modification should be handled by calling code.
32
+ """
33
+
34
+ def __init__(self, app: Optional[Application] = None):
35
+ """
36
+ Initialize the ControlMatcher.
37
+
38
+ :param Optional[Application] app: RegScale Application instance
39
+ """
40
+ self.app = app or Application()
41
+ self.api = Api()
42
+ self._catalog_cache: Dict[int, List[SecurityControl]] = {}
43
+ self._control_impl_cache: Dict[Tuple[int, str], Dict[str, ControlImplementation]] = {}
44
+
45
+ def parse_control_id(self, control_string: str) -> Optional[str]:
46
+ """
47
+ Parse a control ID string and extract the standardized control identifier.
48
+
49
+ Handles various formats:
50
+ - NIST format: AC-1, AC-1(1), AC-1.1
51
+ - With leading zeros: AC-01, AC-17(02)
52
+ - With text: "Access Control AC-1"
53
+ - Multiple controls: "AC-1, AC-2"
54
+
55
+ :param str control_string: Raw control ID string
56
+ :return: Standardized control ID or None if not found
57
+ :rtype: Optional[str]
58
+ """
59
+ if not control_string:
60
+ return None
61
+
62
+ # Clean the string
63
+ control_string = control_string.strip().upper()
64
+
65
+ # Common NIST control ID pattern
66
+ # Matches: AC-1, AC-01, AC-1(1), AC-1(01), AC-1.1, AC-1.01, etc.
67
+ pattern = r"([A-Z]{2,3}-\d+(?:\(\d+\)|\.\d+)?)"
68
+
69
+ matches = re.findall(pattern, control_string)
70
+ if matches:
71
+ # Normalize parentheses to dots for consistency
72
+ control_id = matches[0]
73
+ control_id = control_id.replace("(", ".").replace(")", "")
74
+ return control_id
75
+
76
+ return None
77
+
78
+ def find_control_in_catalog(self, control_id: str, catalog_id: int) -> Optional[SecurityControl]:
79
+ """
80
+ Find a security control in a specific catalog by control ID.
81
+
82
+ :param str control_id: The control ID to search for
83
+ :param int catalog_id: The catalog ID to search in
84
+ :return: SecurityControl object if found, None otherwise
85
+ :rtype: Optional[SecurityControl]
86
+ """
87
+ controls = self._get_catalog_controls(catalog_id)
88
+
89
+ # Generate all possible variations of the control ID
90
+ search_ids = self._get_control_id_variations(control_id)
91
+
92
+ # Try exact match with any variation
93
+ for control in controls:
94
+ if control.controlId in search_ids:
95
+ return control
96
+
97
+ # Try matching control variations against search variations
98
+ for control in controls:
99
+ control_variations = self._get_control_id_variations(control.controlId)
100
+ if control_variations & search_ids: # Set intersection
101
+ return control
102
+
103
+ return None
104
+
105
+ def find_control_implementation(
106
+ self, control_id: str, parent_id: int, parent_module: str = "securityplans", catalog_id: Optional[int] = None
107
+ ) -> Optional[ControlImplementation]:
108
+ """
109
+ Find a control implementation based on control ID and parent context.
110
+
111
+ :param str control_id: The control ID to match
112
+ :param int parent_id: Parent entity ID (e.g., security plan ID)
113
+ :param str parent_module: Parent module type (default: securityplans)
114
+ :param Optional[int] catalog_id: Optional catalog ID for better matching
115
+ :return: ControlImplementation if found, None otherwise
116
+ :rtype: Optional[ControlImplementation]
117
+ """
118
+ # Get control implementations for the parent
119
+ implementations = self._get_control_implementations(parent_id, parent_module)
120
+
121
+ # Get all variations of the control ID for matching
122
+ search_variations = self._get_control_id_variations(control_id)
123
+ if not search_variations:
124
+ logger.warning(f"Could not parse control ID: {control_id}")
125
+ return None
126
+
127
+ # Try to find matching implementation with variation matching
128
+ for impl_key, impl in implementations.items():
129
+ impl_variations = self._get_control_id_variations(impl_key)
130
+ if impl_variations & search_variations: # Set intersection
131
+ return impl
132
+
133
+ # If catalog ID provided, try to find via security control
134
+ if catalog_id:
135
+ control = self.find_control_in_catalog(control_id, catalog_id)
136
+ if control:
137
+ # Search implementations by control ID
138
+ for impl in implementations.values():
139
+ if impl.controlID == control.id:
140
+ return impl
141
+
142
+ return None
143
+
144
+ def match_controls_to_implementations(
145
+ self,
146
+ control_ids: List[str],
147
+ parent_id: int,
148
+ parent_module: str = "securityplans",
149
+ catalog_id: Optional[int] = None,
150
+ ) -> Dict[str, Optional[ControlImplementation]]:
151
+ """
152
+ Match multiple control IDs to their implementations.
153
+
154
+ :param List[str] control_ids: List of control ID strings
155
+ :param int parent_id: Parent entity ID
156
+ :param str parent_module: Parent module type
157
+ :param Optional[int] catalog_id: Optional catalog ID
158
+ :return: Dictionary mapping control IDs to implementations
159
+ :rtype: Dict[str, Optional[ControlImplementation]]
160
+ """
161
+ results = {}
162
+
163
+ for control_id in control_ids:
164
+ impl = self.find_control_implementation(control_id, parent_id, parent_module, catalog_id)
165
+ results[control_id] = impl
166
+
167
+ return results
168
+
169
+ def get_security_plan_controls(self, security_plan_id: int) -> Dict[str, ControlImplementation]:
170
+ """
171
+ Get all control implementations for a security plan.
172
+
173
+ :param int security_plan_id: The security plan ID
174
+ :return: Dictionary of control implementations keyed by control ID
175
+ :rtype: Dict[str, ControlImplementation]
176
+ """
177
+ return self._get_control_implementations(security_plan_id, "securityplans")
178
+
179
+ def find_controls_by_pattern(self, pattern: str, catalog_id: int) -> List[SecurityControl]:
180
+ """
181
+ Find all controls in a catalog matching a pattern.
182
+
183
+ :param str pattern: Regex pattern or substring to match
184
+ :param int catalog_id: Catalog ID to search in
185
+ :return: List of matching SecurityControl objects
186
+ :rtype: List[SecurityControl]
187
+ """
188
+ controls = self._get_catalog_controls(catalog_id)
189
+ matched = []
190
+
191
+ for control in controls:
192
+ if (re.search(pattern, control.controlId, re.IGNORECASE)) or (
193
+ control.title and re.search(pattern, control.title, re.IGNORECASE)
194
+ ):
195
+ matched.append(control)
196
+
197
+ return matched
198
+
199
+ def bulk_match_controls(
200
+ self,
201
+ control_mappings: Dict[str, str],
202
+ parent_id: int,
203
+ parent_module: str = "securityplans",
204
+ catalog_id: Optional[int] = None,
205
+ ) -> Dict[str, Optional[ControlImplementation]]:
206
+ """
207
+ Bulk match control IDs to their implementations.
208
+
209
+ :param Dict[str, str] control_mappings: Dict of {external_id: control_id}
210
+ :param int parent_id: Parent entity ID
211
+ :param str parent_module: Parent module type
212
+ :param Optional[int] catalog_id: Catalog ID for controls
213
+ :return: Dictionary mapping external IDs to ControlImplementations (None if not found)
214
+ :rtype: Dict[str, Optional[ControlImplementation]]
215
+ """
216
+ results = {}
217
+
218
+ for external_id, control_id in control_mappings.items():
219
+ impl = self.find_control_implementation(control_id, parent_id, parent_module, catalog_id)
220
+ results[external_id] = impl
221
+
222
+ return results
223
+
224
+ def _get_catalog_controls(self, catalog_id: int) -> List[SecurityControl]:
225
+ """
226
+ Get all controls for a catalog (with caching).
227
+
228
+ :param int catalog_id: Catalog ID
229
+ :return: List of SecurityControl objects
230
+ :rtype: List[SecurityControl]
231
+ """
232
+ if catalog_id not in self._catalog_cache:
233
+ try:
234
+ controls = SecurityControl.get_list_by_catalog(catalog_id)
235
+ self._catalog_cache[catalog_id] = controls
236
+ except Exception as e:
237
+ logger.error(f"Failed to get controls for catalog {catalog_id}: {e}")
238
+ return []
239
+
240
+ return self._catalog_cache.get(catalog_id, [])
241
+
242
+ def _normalize_control_id(self, control_id: str) -> Optional[str]:
243
+ """
244
+ Normalize a control ID by removing leading zeros from all numeric parts.
245
+
246
+ Examples:
247
+ - AC-01 -> AC-1
248
+ - AC-17(02) -> AC-17.2
249
+ - AC-1.01 -> AC-1.1
250
+
251
+ :param str control_id: The control ID to normalize
252
+ :return: Normalized control ID or None if invalid
253
+ :rtype: Optional[str]
254
+ """
255
+ parsed = self.parse_control_id(control_id)
256
+ if not parsed:
257
+ return None
258
+
259
+ # Split by '-' to get family and number parts
260
+ parts = parsed.split("-")
261
+ if len(parts) != 2:
262
+ return None
263
+
264
+ family = parts[0]
265
+ number_part = parts[1]
266
+
267
+ # Handle enhancement notation (both . and parentheses are normalized to .)
268
+ if "." in number_part:
269
+ main_num, enhancement = number_part.split(".", 1)
270
+ # Remove leading zeros from both parts
271
+ main_num = str(int(main_num))
272
+ enhancement = str(int(enhancement))
273
+ return f"{family}-{main_num}.{enhancement}"
274
+ else:
275
+ # Just main control number
276
+ main_num = str(int(number_part))
277
+ return f"{family}-{main_num}"
278
+
279
+ def _get_control_id_variations(self, control_id: str) -> set:
280
+ """
281
+ Generate all valid variations of a control ID (with and without leading zeros).
282
+
283
+ Examples:
284
+ - AC-1 -> {AC-1, AC-01}
285
+ - AC-17.2 -> {AC-17.2, AC-17.02, AC-17(2), AC-17(02)}
286
+
287
+ :param str control_id: The control ID to generate variations for
288
+ :return: Set of all valid variations
289
+ :rtype: set
290
+ """
291
+ parsed = self.parse_control_id(control_id)
292
+ if not parsed:
293
+ return set()
294
+
295
+ variations = set()
296
+
297
+ # Split by '-' to get family and number parts
298
+ parts = parsed.split("-")
299
+ if len(parts) != 2:
300
+ return set()
301
+
302
+ family = parts[0]
303
+ number_part = parts[1]
304
+
305
+ # Handle enhancement notation
306
+ if "." in number_part:
307
+ main_num, enhancement = number_part.split(".", 1)
308
+ main_int = int(main_num)
309
+ enh_int = int(enhancement)
310
+
311
+ # Generate all combinations: with/without leading zeros, dot/parenthesis notation
312
+ for main_fmt in [str(main_int), f"{main_int:02d}"]:
313
+ for enh_fmt in [str(enh_int), f"{enh_int:02d}"]:
314
+ variations.add(f"{family}-{main_fmt}.{enh_fmt}")
315
+ variations.add(f"{family}-{main_fmt}({enh_fmt})")
316
+ else:
317
+ # Just main control number
318
+ main_int = int(number_part)
319
+ variations.add(f"{family}-{main_int}")
320
+ variations.add(f"{family}-{main_int:02d}")
321
+
322
+ # Add uppercase versions to ensure consistency
323
+ return {v.upper() for v in variations}
324
+
325
+ def _get_control_implementations(self, parent_id: int, parent_module: str) -> Dict[str, ControlImplementation]:
326
+ """
327
+ Get control implementations for a parent (with caching).
328
+
329
+ :param int parent_id: Parent ID
330
+ :param str parent_module: Parent module
331
+ :return: Dict of implementations keyed by control ID
332
+ :rtype: Dict[str, ControlImplementation]
333
+ """
334
+ cache_key = (parent_id, parent_module)
335
+
336
+ if cache_key not in self._control_impl_cache:
337
+ try:
338
+ # Get the label map which maps control IDs to implementation IDs
339
+ label_map = ControlImplementation.get_control_label_map_by_parent(parent_id, parent_module)
340
+
341
+ implementations = {}
342
+ for control_label, impl_id in label_map.items():
343
+ impl = ControlImplementation.get_object(impl_id)
344
+ if impl:
345
+ implementations[control_label] = impl
346
+
347
+ self._control_impl_cache[cache_key] = implementations
348
+ except Exception as e:
349
+ logger.error(f"Failed to get implementations for {parent_module}/{parent_id}: {e}")
350
+ return {}
351
+
352
+ return self._control_impl_cache.get(cache_key, {})
353
+
354
+ def clear_cache(self):
355
+ """Clear all cached data."""
356
+ self._catalog_cache.clear()
357
+ self._control_impl_cache.clear()
358
+ logger.info("Cleared control matcher cache")
@@ -7,13 +7,16 @@ from datetime import datetime
7
7
  from typing import Any, Dict, Optional
8
8
 
9
9
  from regscale.core.app.application import Application
10
- from regscale.core.utils.date import date_str, get_day_increment
10
+ from regscale.core.utils.date import get_day_increment
11
11
  from regscale.integrations.public.cisa import pull_cisa_kev
12
12
  from regscale.models import regscale_models
13
13
  from regscale.utils.threading import ThreadSafeDict
14
14
 
15
15
  logger = logging.getLogger("regscale")
16
16
 
17
+ # Date format constant for consistent datetime string formatting
18
+ DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
19
+
17
20
 
18
21
  class DueDateHandler:
19
22
  """
@@ -21,6 +24,19 @@ class DueDateHandler:
21
24
  1. Init.yaml timeline configurations per integration
22
25
  2. KEV (Known Exploited Vulnerabilities) dates from CISA
23
26
  3. Default severity-based timelines
27
+ 4. Configurable past due date validation (noPastDueDates setting)
28
+
29
+ Configuration Options:
30
+ - Global setting: issues.noPastDueDates (default: true)
31
+ - Per-integration: issues.{integration_name}.noPastDueDates
32
+
33
+ When noPastDueDates=true (default):
34
+ - Due dates calculated in the past are automatically adjusted to future dates
35
+ - Prevents API validation errors for past due dates
36
+
37
+ When noPastDueDates=false:
38
+ - Original due dates are preserved, even if they're in the past
39
+ - Useful for historical data or when past dates are intentionally required
24
40
  """
25
41
 
26
42
  def __init__(self, integration_name: str, config: Optional[Dict[str, Any]] = None):
@@ -45,6 +61,9 @@ class DueDateHandler:
45
61
  # Load integration-specific timelines from config
46
62
  self.integration_timelines = self._load_integration_timelines()
47
63
 
64
+ # Load noPastDueDates setting (defaults to True)
65
+ self.no_past_due_dates = self._load_no_past_due_dates_setting()
66
+
48
67
  def _load_integration_timelines(self) -> Dict[regscale_models.IssueSeverity, int]:
49
68
  """
50
69
  Load timeline configurations for this integration from init.yaml
@@ -75,6 +94,37 @@ class DueDateHandler:
75
94
 
76
95
  return timelines
77
96
 
97
+ def _load_no_past_due_dates_setting(self) -> bool:
98
+ """
99
+ Load noPastDueDates setting for this integration from init.yaml
100
+
101
+ Configuration hierarchy:
102
+ 1. Integration-specific setting: issues.{integration_name}.noPastDueDates
103
+ 2. Global setting: issues.noPastDueDates
104
+ 3. Default: True
105
+
106
+ :return: True if past due dates should be automatically adjusted to future dates
107
+ :rtype: bool
108
+ """
109
+ issues_config = self.config.get("issues", {})
110
+ integration_config = issues_config.get(self.integration_name, {})
111
+
112
+ # Check integration-specific setting first
113
+ if "noPastDueDates" in integration_config:
114
+ setting = integration_config["noPastDueDates"]
115
+ logger.debug(f"Using integration-specific noPastDueDates={setting} for {self.integration_name}")
116
+ return bool(setting)
117
+
118
+ # Fall back to global setting
119
+ if "noPastDueDates" in issues_config:
120
+ setting = issues_config["noPastDueDates"]
121
+ logger.debug(f"Using global noPastDueDates={setting} for {self.integration_name}")
122
+ return bool(setting)
123
+
124
+ # Default to True (prevent past due dates)
125
+ logger.debug(f"Using default noPastDueDates=True for {self.integration_name}")
126
+ return True
127
+
78
128
  def _get_kev_data(self) -> ThreadSafeDict:
79
129
  """
80
130
  Get KEV data from CISA, using cache if available
@@ -160,17 +210,26 @@ class DueDateHandler:
160
210
  kev_date = date_parse(kev_due_date).date()
161
211
  created_dt = date_parse(created_date).date()
162
212
 
163
- # If KEV due date is after creation date, use KEV date
213
+ # If KEV due date is after creation date, use KEV date but ensure it's not in the past
164
214
  # If KEV due date is before creation date, add the difference to creation date
165
215
  if kev_date >= created_dt:
166
- logger.debug(f"Using KEV due date {kev_due_date} for CVE {cve}")
167
- return kev_due_date
216
+ # Ensure KEV due date is not in the past
217
+ kev_due_validated = self._ensure_future_due_date(
218
+ kev_due_date, 30
219
+ ) # Use 30 days as fallback for KEV
220
+ logger.debug(f"Using KEV due date {kev_due_validated} for CVE {cve}")
221
+ return kev_due_validated
168
222
  else:
169
223
  # KEV date has passed, calculate new due date from creation
170
224
  days_diff = (created_dt - kev_date).days
171
225
  # Give at least 30 days from creation for critical KEV items
172
226
  adjusted_days = max(30, days_diff)
173
- due_date = date_str(get_day_increment(start=created_date, days=adjusted_days))
227
+ calculated_due_date_obj = get_day_increment(start=created_date, days=adjusted_days)
228
+ calculated_due_date = datetime.combine(calculated_due_date_obj, datetime.min.time()).strftime(
229
+ DATETIME_FORMAT
230
+ )
231
+ # Ensure the adjusted due date is not in the past
232
+ due_date = self._ensure_future_due_date(calculated_due_date, adjusted_days)
174
233
  logger.debug(f"KEV date passed, using adjusted due date {due_date} for CVE {cve}")
175
234
  return due_date
176
235
 
@@ -179,12 +238,64 @@ class DueDateHandler:
179
238
 
180
239
  # Fall back to severity-based timeline from integration config
181
240
  days = self.integration_timelines.get(severity, self.default_timelines[severity])
182
- due_date = date_str(get_day_increment(start=created_date, days=days))
241
+ calculated_due_date_obj = get_day_increment(start=created_date, days=days)
242
+ calculated_due_date = datetime.combine(calculated_due_date_obj, datetime.min.time()).strftime(DATETIME_FORMAT)
243
+
244
+ # Ensure due date is never in the past (allow yesterday for timezone differences)
245
+ due_date = self._ensure_future_due_date(calculated_due_date, days)
183
246
 
184
247
  logger.debug(f"Using {self.integration_name} timeline: {severity.name} = {days} days, due date = {due_date}")
185
248
 
186
249
  return due_date
187
250
 
251
+ def _ensure_future_due_date(self, calculated_due_date: str, original_days: int) -> str:
252
+ """
253
+ Ensure the due date is not in the past. If it is, set it to today + original timeline days.
254
+ Behavior is controlled by the noPastDueDates configuration setting.
255
+
256
+ :param str calculated_due_date: The originally calculated due date
257
+ :param int original_days: The original number of days for this severity
258
+ :return: A due date that respects the noPastDueDates setting
259
+ :rtype: str
260
+ """
261
+ # If noPastDueDates is disabled, return the original date without validation
262
+ if not self.no_past_due_dates:
263
+ logger.debug(
264
+ f"noPastDueDates=False for {self.integration_name}, returning original due date: {calculated_due_date}"
265
+ )
266
+ return calculated_due_date
267
+
268
+ from dateutil.parser import parse as date_parse
269
+ from datetime import date
270
+
271
+ try:
272
+ calculated_date = date_parse(calculated_due_date).date()
273
+ today = date.today()
274
+
275
+ if calculated_date >= today:
276
+ return calculated_due_date
277
+ else:
278
+ # Due date is in the past, calculate new due date from today
279
+ # Use minimum 1 day to ensure it's always in the future
280
+ safe_days = max(1, original_days)
281
+ new_due_date_obj = get_day_increment(start=today, days=safe_days)
282
+ new_due_date = datetime.combine(new_due_date_obj, datetime.min.time()).strftime(DATETIME_FORMAT)
283
+ logger.debug(
284
+ f"Due date {calculated_due_date} was in the past for {self.integration_name}. "
285
+ f"Adjusted to {new_due_date} ({safe_days} days from today)."
286
+ )
287
+ return new_due_date
288
+
289
+ except Exception as e:
290
+ logger.warning(f"Failed to validate due date {calculated_due_date}: {e}")
291
+ # If we can't parse the date, return a safe fallback only if noPastDueDates is enabled
292
+ if self.no_past_due_dates:
293
+ safe_days = max(1, original_days)
294
+ fallback_due_date_obj = get_day_increment(start=date.today(), days=safe_days)
295
+ return datetime.combine(fallback_due_date_obj, datetime.min.time()).strftime(DATETIME_FORMAT)
296
+ else:
297
+ return calculated_due_date
298
+
188
299
  def get_integration_config(self) -> Dict[str, Any]:
189
300
  """
190
301
  Get the full integration configuration from init.yaml
@@ -205,6 +316,7 @@ class DueDateHandler:
205
316
  return {
206
317
  "integration_name": self.integration_name,
207
318
  "use_kev": self._should_use_kev(),
319
+ "no_past_due_dates": self.no_past_due_dates,
208
320
  "timelines": {severity.name: days for severity, days in self.integration_timelines.items()},
209
321
  "config_source": "init.yaml",
210
322
  }