regscale-cli 6.25.1.0__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 (80) 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/wizv2/click.py +26 -26
  11. regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
  12. regscale/integrations/commercial/wizv2/scanner.py +3 -3
  13. regscale/integrations/compliance_integration.py +67 -2
  14. regscale/integrations/control_matcher.py +358 -0
  15. regscale/integrations/milestone_manager.py +291 -0
  16. regscale/integrations/public/__init__.py +1 -0
  17. regscale/integrations/public/cci_importer.py +37 -38
  18. regscale/integrations/public/fedramp/click.py +60 -2
  19. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  20. regscale/integrations/scanner_integration.py +150 -96
  21. regscale/models/integration_models/cisa_kev_data.json +154 -4
  22. regscale/models/integration_models/nexpose.py +36 -10
  23. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  24. regscale/models/locking.py +12 -8
  25. regscale/models/platform.py +1 -2
  26. regscale/models/regscale_models/control_implementation.py +46 -21
  27. regscale/models/regscale_models/issue.py +256 -94
  28. regscale/models/regscale_models/milestone.py +1 -1
  29. regscale/models/regscale_models/regscale_model.py +6 -1
  30. regscale/templates/__init__.py +0 -0
  31. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
  32. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +80 -33
  33. tests/regscale/integrations/commercial/__init__.py +0 -0
  34. tests/regscale/integrations/commercial/conftest.py +28 -0
  35. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  36. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  37. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  38. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  39. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  40. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  41. tests/regscale/integrations/commercial/test_burp.py +48 -0
  42. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  43. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  44. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  45. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  46. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  47. tests/regscale/integrations/commercial/test_jira.py +1814 -0
  48. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  49. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  50. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  51. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  52. tests/regscale/integrations/commercial/test_snow.py +423 -0
  53. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  54. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  55. tests/regscale/integrations/commercial/test_stig.py +33 -0
  56. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  57. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  58. tests/regscale/integrations/commercial/test_wiz.py +1469 -0
  59. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  60. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  61. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  62. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  63. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  64. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
  65. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  66. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  67. tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
  68. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  69. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
  70. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
  71. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  72. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  73. tests/regscale/integrations/test_control_matcher.py +1314 -0
  74. tests/regscale/integrations/test_control_matching.py +155 -0
  75. tests/regscale/integrations/test_milestone_manager.py +408 -0
  76. tests/regscale/models/test_issue.py +378 -1
  77. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
  78. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
  79. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
  80. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/top_level.txt +0 -0
@@ -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")
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Milestone Manager for Issue Tracking
5
+
6
+ Handles creation of milestones for issues based on status transitions (created, reopened, closed).
7
+ Also handles backfilling of missing milestones for existing issues.
8
+ """
9
+ import logging
10
+ from typing import TYPE_CHECKING, List, Optional
11
+
12
+ from regscale.core.app.utils.app_utils import get_current_datetime
13
+ from regscale.integrations.variables import ScannerVariables
14
+ from regscale.models import regscale_models
15
+
16
+ if TYPE_CHECKING:
17
+ from regscale.integrations.scanner_integration import IntegrationFinding
18
+
19
+ logger = logging.getLogger("regscale")
20
+
21
+
22
+ class MilestoneManager:
23
+ """
24
+ Manages milestone creation for issues based on status transitions.
25
+
26
+ Milestones are created when:
27
+ - A new issue is created
28
+ - An existing issue is reopened (Closed -> Open)
29
+ - An existing issue is closed (Open -> Closed)
30
+ """
31
+
32
+ def __init__(self, integration_title: str, assessor_id: str, scan_date: str):
33
+ """
34
+ Initialize the milestone manager.
35
+
36
+ :param str integration_title: Name of the integration (used in milestone titles)
37
+ :param str assessor_id: ID of the assessor/responsible person for milestones
38
+ :param str scan_date: Date of the scan (used for new issue milestones)
39
+ """
40
+ self.integration_title = integration_title
41
+ self.assessor_id = assessor_id
42
+ self.scan_date = scan_date
43
+
44
+ def create_milestones_for_issue(
45
+ self,
46
+ issue: regscale_models.Issue,
47
+ finding: Optional["IntegrationFinding"] = None,
48
+ existing_issue: Optional[regscale_models.Issue] = None,
49
+ ) -> None:
50
+ """
51
+ Create appropriate milestones for an issue based on status transitions.
52
+
53
+ :param regscale_models.Issue issue: The issue to create milestones for
54
+ :param Optional[IntegrationFinding] finding: The finding data (for logging/context)
55
+ :param Optional[regscale_models.Issue] existing_issue: Previous state of issue for comparison
56
+ """
57
+ if not self._should_create_milestones(issue):
58
+ return
59
+
60
+ if self._should_create_reopened_milestone(existing_issue, issue):
61
+ self._create_reopened_milestone(issue, finding)
62
+ elif self._should_create_closed_milestone(existing_issue, issue):
63
+ self._create_closed_milestone(issue, finding)
64
+ elif not existing_issue:
65
+ self._create_new_issue_milestone(issue, finding)
66
+ else:
67
+ logger.debug(
68
+ "No milestone created for issue %s (no status transition detected)",
69
+ issue.id,
70
+ )
71
+
72
+ def _should_create_milestones(self, issue: regscale_models.Issue) -> bool:
73
+ """
74
+ Check if milestones should be created for this issue.
75
+
76
+ :param regscale_models.Issue issue: The issue to check
77
+ :return: True if milestones should be created
78
+ :rtype: bool
79
+ """
80
+ if not ScannerVariables.useMilestones:
81
+ logger.debug("Milestone creation disabled (useMilestones=False)")
82
+ return False
83
+
84
+ if not issue.id:
85
+ logger.debug("Cannot create milestone - issue has no ID")
86
+ return False
87
+
88
+ return True
89
+
90
+ def _should_create_reopened_milestone(
91
+ self, existing_issue: Optional[regscale_models.Issue], issue: regscale_models.Issue
92
+ ) -> bool:
93
+ """
94
+ Check if a reopened milestone should be created.
95
+
96
+ :param Optional[regscale_models.Issue] existing_issue: The existing issue
97
+ :param regscale_models.Issue issue: The current issue
98
+ :return: True if reopened milestone should be created
99
+ :rtype: bool
100
+ """
101
+ return (
102
+ existing_issue is not None
103
+ and existing_issue.status == regscale_models.IssueStatus.Closed
104
+ and issue.status == regscale_models.IssueStatus.Open
105
+ )
106
+
107
+ def _should_create_closed_milestone(
108
+ self, existing_issue: Optional[regscale_models.Issue], issue: regscale_models.Issue
109
+ ) -> bool:
110
+ """
111
+ Check if a closed milestone should be created.
112
+
113
+ :param Optional[regscale_models.Issue] existing_issue: The existing issue
114
+ :param regscale_models.Issue issue: The current issue
115
+ :return: True if closed milestone should be created
116
+ :rtype: bool
117
+ """
118
+ return (
119
+ existing_issue is not None
120
+ and existing_issue.status == regscale_models.IssueStatus.Open
121
+ and issue.status == regscale_models.IssueStatus.Closed
122
+ )
123
+
124
+ def _create_reopened_milestone(
125
+ self,
126
+ issue: regscale_models.Issue,
127
+ finding: Optional["IntegrationFinding"] = None,
128
+ ) -> None:
129
+ """
130
+ Create a milestone for a reopened issue.
131
+
132
+ :param regscale_models.Issue issue: The issue being reopened
133
+ :param Optional[IntegrationFinding] finding: The finding data (for logging)
134
+ """
135
+ milestone_date = get_current_datetime()
136
+ self._create_milestone(
137
+ issue=issue,
138
+ title=f"Issue reopened from {self.integration_title} scan",
139
+ milestone_date=milestone_date,
140
+ milestone_type="reopened",
141
+ finding=finding,
142
+ )
143
+
144
+ def _create_closed_milestone(
145
+ self,
146
+ issue: regscale_models.Issue,
147
+ finding: Optional["IntegrationFinding"] = None,
148
+ ) -> None:
149
+ """
150
+ Create a milestone for a closed issue.
151
+
152
+ :param regscale_models.Issue issue: The issue being closed
153
+ :param Optional[IntegrationFinding] finding: The finding data (for logging)
154
+ """
155
+ milestone_date = issue.dateCompleted or get_current_datetime()
156
+ self._create_milestone(
157
+ issue=issue,
158
+ title=f"Issue closed from {self.integration_title} scan",
159
+ milestone_date=milestone_date,
160
+ milestone_type="closed",
161
+ finding=finding,
162
+ )
163
+
164
+ def _create_new_issue_milestone(
165
+ self,
166
+ issue: regscale_models.Issue,
167
+ finding: Optional["IntegrationFinding"] = None,
168
+ ) -> None:
169
+ """
170
+ Create a milestone for a newly created issue.
171
+
172
+ :param regscale_models.Issue issue: The newly created issue
173
+ :param Optional[IntegrationFinding] finding: The finding data (for logging)
174
+ """
175
+ self._create_milestone(
176
+ issue=issue,
177
+ title=f"Issue created from {self.integration_title} scan",
178
+ milestone_date=self.scan_date,
179
+ milestone_type="new",
180
+ finding=finding,
181
+ )
182
+
183
+ def _create_milestone(
184
+ self,
185
+ issue: regscale_models.Issue,
186
+ title: str,
187
+ milestone_date: str,
188
+ milestone_type: str,
189
+ finding: Optional["IntegrationFinding"] = None,
190
+ ) -> None:
191
+ """
192
+ Create a milestone with error handling.
193
+
194
+ :param regscale_models.Issue issue: The issue to create milestone for
195
+ :param str title: Title of the milestone
196
+ :param str milestone_date: Date for the milestone
197
+ :param str milestone_type: Type of milestone (for logging: new, reopened, closed)
198
+ :param Optional[IntegrationFinding] finding: The finding data (for logging)
199
+ """
200
+ try:
201
+ regscale_models.Milestone(
202
+ title=title,
203
+ milestoneDate=milestone_date,
204
+ dateCompleted=get_current_datetime(),
205
+ responsiblePersonId=self.assessor_id,
206
+ parentID=issue.id,
207
+ parentModule="issues",
208
+ ).create_or_update()
209
+
210
+ logger.debug(f"Created {milestone_type} milestone for issue {issue.id}")
211
+
212
+ except Exception as e:
213
+ logger.warning(f"Failed to create {milestone_type} milestone for issue {issue.id}: {e}")
214
+
215
+ def get_existing_milestones(self, issue: regscale_models.Issue) -> List[regscale_models.Milestone]:
216
+ """
217
+ Get all existing milestones for an issue.
218
+
219
+ :param regscale_models.Issue issue: The issue to check
220
+ :return: List of existing milestones
221
+ :rtype: List[regscale_models.Milestone]
222
+ """
223
+ if not issue.id:
224
+ return []
225
+
226
+ try:
227
+ milestones = regscale_models.Milestone.get_by_parent(parent_id=issue.id, parent_module="issues")
228
+ return milestones if milestones else []
229
+ except Exception as e:
230
+ logger.debug(f"Could not retrieve milestones for issue {issue.id}: {e}")
231
+ return []
232
+
233
+ def has_creation_milestone(self, issue: regscale_models.Issue) -> bool:
234
+ """
235
+ Check if an issue has a creation milestone.
236
+
237
+ :param regscale_models.Issue issue: The issue to check
238
+ :return: True if creation milestone exists
239
+ :rtype: bool
240
+ """
241
+ milestones = self.get_existing_milestones(issue)
242
+
243
+ # Check for creation milestone patterns
244
+ creation_patterns = [
245
+ "Issue created from",
246
+ "created from",
247
+ ]
248
+
249
+ for milestone in milestones:
250
+ milestone_title = milestone.title.lower() if milestone.title else ""
251
+ if any(pattern.lower() in milestone_title for pattern in creation_patterns):
252
+ logger.debug(f"Found existing creation milestone for issue {issue.id}: {milestone.title}")
253
+ return True
254
+
255
+ return False
256
+
257
+ def ensure_creation_milestone_exists(
258
+ self,
259
+ issue: regscale_models.Issue,
260
+ finding: Optional["IntegrationFinding"] = None,
261
+ ) -> None:
262
+ """
263
+ Ensure an issue has a creation milestone, backfilling if necessary.
264
+
265
+ This method checks if an issue has a creation milestone. If not, it creates one
266
+ based on the issue's dateCreated field to backfill missing milestones.
267
+
268
+ :param regscale_models.Issue issue: The issue to check
269
+ :param Optional[IntegrationFinding] finding: The finding data (for logging)
270
+ """
271
+ if not self._should_create_milestones(issue):
272
+ return
273
+
274
+ # Check if creation milestone already exists
275
+ if self.has_creation_milestone(issue):
276
+ logger.debug(f"Issue {issue.id} already has creation milestone, skipping backfill")
277
+ return
278
+
279
+ # Backfill missing creation milestone
280
+ logger.debug(f"Backfilling missing creation milestone for issue {issue.id}")
281
+
282
+ # Use issue's dateCreated if available, otherwise use scan_date
283
+ milestone_date = issue.dateCreated if issue.dateCreated else self.scan_date
284
+
285
+ self._create_milestone(
286
+ issue=issue,
287
+ title=f"Issue created from {self.integration_title} scan",
288
+ milestone_date=milestone_date,
289
+ milestone_type="backfilled",
290
+ finding=finding,
291
+ )
@@ -17,6 +17,7 @@ from regscale.core.lazy_group import LazyGroup
17
17
  "import_poam": "regscale.integrations.public.fedramp.click.import_fedramp_poam_template",
18
18
  "import_drf": "regscale.integrations.public.fedramp.click.import_drf",
19
19
  "import_cis_crm": "regscale.integrations.public.fedramp.click.import_ciscrm",
20
+ "export_poam_v5": "regscale.integrations.public.fedramp.click.export_poam_v5",
20
21
  },
21
22
  name="fedramp",
22
23
  )