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.
- regscale/_version.py +1 -1
- regscale/airflow/hierarchy.py +2 -2
- regscale/core/app/application.py +18 -3
- regscale/core/app/internal/login.py +0 -1
- regscale/core/app/utils/catalog_utils/common.py +1 -1
- regscale/integrations/commercial/sicura/api.py +14 -13
- regscale/integrations/commercial/sicura/commands.py +8 -2
- regscale/integrations/commercial/sicura/scanner.py +49 -39
- regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
- regscale/integrations/commercial/wizv2/click.py +26 -26
- regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
- regscale/integrations/commercial/wizv2/scanner.py +3 -3
- regscale/integrations/compliance_integration.py +67 -2
- regscale/integrations/control_matcher.py +358 -0
- regscale/integrations/milestone_manager.py +291 -0
- regscale/integrations/public/__init__.py +1 -0
- regscale/integrations/public/cci_importer.py +37 -38
- regscale/integrations/public/fedramp/click.py +60 -2
- regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
- regscale/integrations/scanner_integration.py +150 -96
- regscale/models/integration_models/cisa_kev_data.json +154 -4
- regscale/models/integration_models/nexpose.py +36 -10
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/locking.py +12 -8
- regscale/models/platform.py +1 -2
- regscale/models/regscale_models/control_implementation.py +46 -21
- regscale/models/regscale_models/issue.py +256 -94
- regscale/models/regscale_models/milestone.py +1 -1
- regscale/models/regscale_models/regscale_model.py +6 -1
- regscale/templates/__init__.py +0 -0
- {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
- {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +80 -33
- tests/regscale/integrations/commercial/__init__.py +0 -0
- tests/regscale/integrations/commercial/conftest.py +28 -0
- tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
- tests/regscale/integrations/commercial/test_aws.py +3731 -0
- tests/regscale/integrations/commercial/test_burp.py +48 -0
- tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
- tests/regscale/integrations/commercial/test_dependabot.py +341 -0
- tests/regscale/integrations/commercial/test_gcp.py +1543 -0
- tests/regscale/integrations/commercial/test_gitlab.py +549 -0
- tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
- tests/regscale/integrations/commercial/test_jira.py +1814 -0
- tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
- tests/regscale/integrations/commercial/test_okta.py +1228 -0
- tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
- tests/regscale/integrations/commercial/test_sicura.py +350 -0
- tests/regscale/integrations/commercial/test_snow.py +423 -0
- tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
- tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
- tests/regscale/integrations/commercial/test_stig.py +33 -0
- tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
- tests/regscale/integrations/commercial/test_stigv2.py +406 -0
- tests/regscale/integrations/commercial/test_wiz.py +1469 -0
- tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
- tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
- tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
- tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
- tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
- tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
- tests/regscale/integrations/public/fedramp/__init__.py +1 -0
- tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
- tests/regscale/integrations/test_control_matcher.py +1314 -0
- tests/regscale/integrations/test_control_matching.py +155 -0
- tests/regscale/integrations/test_milestone_manager.py +408 -0
- tests/regscale/models/test_issue.py +378 -1
- {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
)
|