regscale-cli 6.21.2.2__py3-none-any.whl → 6.22.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/core/app/application.py +3 -0
- regscale/core/app/utils/app_utils.py +31 -0
- regscale/integrations/commercial/jira.py +27 -5
- regscale/integrations/commercial/qualys/__init__.py +160 -60
- regscale/integrations/commercial/qualys/scanner.py +300 -39
- regscale/integrations/commercial/wizv2/async_client.py +4 -0
- regscale/integrations/commercial/wizv2/scanner.py +50 -24
- regscale/integrations/public/__init__.py +13 -0
- regscale/integrations/public/fedramp/fedramp_cis_crm.py +175 -51
- regscale/integrations/scanner_integration.py +513 -145
- regscale/models/integration_models/cisa_kev_data.json +34 -3
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/regscale_models/__init__.py +2 -0
- regscale/models/regscale_models/catalog.py +1 -1
- regscale/models/regscale_models/control_implementation.py +8 -8
- regscale/models/regscale_models/form_field_value.py +5 -3
- regscale/models/regscale_models/inheritance.py +44 -0
- regscale/regscale.py +2 -0
- {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/METADATA +1 -1
- {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/RECORD +26 -28
- tests/regscale/models/test_tenable_integrations.py +811 -105
- regscale/integrations/public/fedramp/mappings/fedramp_r4_parts.json +0 -7388
- regscale/integrations/public/fedramp/mappings/fedramp_r5_parts.json +0 -9605
- regscale/integrations/public/fedramp/parts_mapper.py +0 -107
- {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/entry_points.txt +0 -0
- {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/top_level.txt +0 -0
|
@@ -8,7 +8,6 @@ import json
|
|
|
8
8
|
import math
|
|
9
9
|
import re
|
|
10
10
|
import shutil
|
|
11
|
-
import tempfile
|
|
12
11
|
from collections import Counter
|
|
13
12
|
from concurrent.futures import as_completed
|
|
14
13
|
from concurrent.futures.thread import ThreadPoolExecutor
|
|
@@ -24,7 +23,6 @@ from regscale.core.app.api import Api
|
|
|
24
23
|
from regscale.core.app.utils.api_handler import APIInsertionError, APIUpdateError
|
|
25
24
|
from regscale.core.app.utils.app_utils import compute_hash, create_progress_object, error_and_exit, get_current_datetime
|
|
26
25
|
from regscale.core.utils.graphql import GraphQLQuery
|
|
27
|
-
from regscale.integrations.public.fedramp.parts_mapper import PartMapper
|
|
28
26
|
from regscale.integrations.public.fedramp.ssp_logger import SSPLogger
|
|
29
27
|
from regscale.models import ControlObjective, ImplementationObjective, ImportValidater, Parameter, Profile
|
|
30
28
|
from regscale.models.regscale_models import (
|
|
@@ -49,8 +47,6 @@ from tempfile import gettempdir
|
|
|
49
47
|
T = TypeVar("T")
|
|
50
48
|
|
|
51
49
|
logger = SSPLogger()
|
|
52
|
-
part_mapper_rev5 = PartMapper()
|
|
53
|
-
part_mapper_rev4 = PartMapper()
|
|
54
50
|
progress = create_progress_object()
|
|
55
51
|
|
|
56
52
|
SERVICE_PROVIDER_CORPORATE = "Service Provider Corporate"
|
|
@@ -120,6 +116,133 @@ def get_pandas() -> ModuleType:
|
|
|
120
116
|
return pd
|
|
121
117
|
|
|
122
118
|
|
|
119
|
+
def smart_find_by_source(
|
|
120
|
+
source: str, control_objectives: List[ControlObjective]
|
|
121
|
+
) -> Tuple[Optional[str], List[str], str]:
|
|
122
|
+
"""
|
|
123
|
+
Smart algorithm to find mapping by source, checking ControlObjective table only.
|
|
124
|
+
|
|
125
|
+
:param str source: The source control ID (e.g., "AC-1(a)", "AC-01 (a)")
|
|
126
|
+
:param List[ControlObjective] control_objectives: List of ControlObjective objects to search
|
|
127
|
+
:return: Tuple of (primary_oscal_id, sub_parts, status_message)
|
|
128
|
+
:rtype: Tuple[Optional[str], List[str], str]
|
|
129
|
+
"""
|
|
130
|
+
# Step 1: Convert control name to OSCAL identifier
|
|
131
|
+
expected_oscal = _convert_to_oscal_identifier(source)
|
|
132
|
+
|
|
133
|
+
if not expected_oscal:
|
|
134
|
+
return None, [], f"Unable to convert control {source} to OSCAL format"
|
|
135
|
+
|
|
136
|
+
# Step 2: Search otherId field in ControlObjective table for exact match
|
|
137
|
+
if _find_exact_objective_by_other_id(expected_oscal, control_objectives):
|
|
138
|
+
return expected_oscal, [], f"Found exact match: {expected_oscal}"
|
|
139
|
+
|
|
140
|
+
# Step 3: Search for subparts (pattern: expected_oscal + ".*")
|
|
141
|
+
if sub_parts := _find_subpart_objectives_by_other_id(expected_oscal, control_objectives):
|
|
142
|
+
return None, sub_parts, f"Control exists with {len(sub_parts)} sub-parts. Update import file."
|
|
143
|
+
|
|
144
|
+
# Step 4: No match found
|
|
145
|
+
return None, [], f"No database match found for {source} (expected: {expected_oscal})"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _convert_to_oscal_identifier(source: str) -> Optional[str]:
|
|
149
|
+
"""
|
|
150
|
+
Convert control name to OSCAL identifier using algorithmic patterns.
|
|
151
|
+
|
|
152
|
+
:param str source: The source control ID (e.g., "AC-1(a)", "AC-01 (a)", "AC-6(1)")
|
|
153
|
+
:return: Generated OSCAL identifier or None
|
|
154
|
+
:rtype: Optional[str]
|
|
155
|
+
"""
|
|
156
|
+
# Remove extra spaces and normalize
|
|
157
|
+
source = source.strip()
|
|
158
|
+
|
|
159
|
+
# Pattern 1: Control enhancement - AC-6(1), AC-02 (01), AC-6 ( 1 )
|
|
160
|
+
if match := re.match(r"^([A-Z]{2})-(\d{1,2})\s*\(\s*(\d{1,2})\s*\)$", source, re.IGNORECASE):
|
|
161
|
+
family, number, enhancement = match.groups()
|
|
162
|
+
return f"{family.lower()}-{int(number)}.{int(enhancement)}_smt"
|
|
163
|
+
|
|
164
|
+
# Pattern 2: Control part - AC-1(a), AC-01 (a), AC-1 ( a )
|
|
165
|
+
if match := re.match(r"^([A-Z]{2})-(\d{1,2})\s*\(\s*([a-z])\s*\)$", source, re.IGNORECASE):
|
|
166
|
+
family, number, part = match.groups()
|
|
167
|
+
return f"{family.lower()}-{int(number)}_smt.{part.lower()}"
|
|
168
|
+
|
|
169
|
+
# Pattern 3: Control enhancement part - AC-6(1)(a), AC-02 (07) (a), AC-6 ( 1 ) ( a )
|
|
170
|
+
if match := re.match(r"^([A-Z]{2})-(\d{1,2})\s*\(\s*(\d{1,2})\s*\)\s*\(\s*([a-z])\s*\)$", source, re.IGNORECASE):
|
|
171
|
+
family, number, enhancement, part = match.groups()
|
|
172
|
+
return f"{family.lower()}-{int(number)}.{int(enhancement)}_smt.{part.lower()}"
|
|
173
|
+
|
|
174
|
+
# Pattern 4: Base control - AC-1, AC-01
|
|
175
|
+
if match := re.match(r"^([A-Z]{2})-(\d{1,2})$", source, re.IGNORECASE):
|
|
176
|
+
family, number = match.groups()
|
|
177
|
+
return f"{family.lower()}-{int(number)}_smt"
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _find_exact_objective_by_other_id(expected_oscal: str, control_objectives: List[ControlObjective]) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Check if exact OSCAL identifier exists in ControlObjective otherId field.
|
|
185
|
+
|
|
186
|
+
:param str expected_oscal: The expected OSCAL identifier
|
|
187
|
+
:param List[ControlObjective] control_objectives: List of ControlObjective objects
|
|
188
|
+
:return: True if exact match found
|
|
189
|
+
:rtype: bool
|
|
190
|
+
"""
|
|
191
|
+
for obj in control_objectives:
|
|
192
|
+
if hasattr(obj, "otherId") and obj.otherId == expected_oscal:
|
|
193
|
+
return True
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _convert_oscal_to_rev4_control_label(oscal_control_id: str) -> str:
|
|
198
|
+
"""
|
|
199
|
+
Convert OSCAL control ID to Rev4 control label format.
|
|
200
|
+
|
|
201
|
+
Examples:
|
|
202
|
+
- "ac-1" -> "ac-01"
|
|
203
|
+
- "ac-10" -> "ac-10"
|
|
204
|
+
- "ac-2.7" -> "ac-02"
|
|
205
|
+
|
|
206
|
+
:param str oscal_control_id: OSCAL control ID (e.g., "ac-1", "ac-2.7")
|
|
207
|
+
:return: Rev4 control label (e.g., "ac-01", "ac-02")
|
|
208
|
+
:rtype: str
|
|
209
|
+
"""
|
|
210
|
+
# Handle control enhancements by taking just the base control
|
|
211
|
+
base_control = oscal_control_id.split(".")[0] # "ac-2.7" -> "ac-2"
|
|
212
|
+
|
|
213
|
+
# Split into family and number
|
|
214
|
+
parts = base_control.split("-")
|
|
215
|
+
if len(parts) != 2:
|
|
216
|
+
return oscal_control_id # Return as-is if not in expected format
|
|
217
|
+
|
|
218
|
+
family, number = parts
|
|
219
|
+
|
|
220
|
+
# Convert single digit to zero-padded format: "1" -> "01"
|
|
221
|
+
if len(number) == 1:
|
|
222
|
+
number = f"0{number}"
|
|
223
|
+
|
|
224
|
+
return f"{family}-{number}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _find_subpart_objectives_by_other_id(base_oscal: str, control_objectives: List[ControlObjective]) -> List[str]:
|
|
228
|
+
"""
|
|
229
|
+
Find sub-part objectives that start with the base OSCAL identifier pattern.
|
|
230
|
+
|
|
231
|
+
:param str base_oscal: The base OSCAL identifier (e.g., "ac-2.7_smt")
|
|
232
|
+
:param List[ControlObjective] control_objectives: List of ControlObjective objects
|
|
233
|
+
:return: List of sub-part OSCAL identifiers
|
|
234
|
+
:rtype: List[str]
|
|
235
|
+
"""
|
|
236
|
+
sub_parts = []
|
|
237
|
+
base_pattern = base_oscal + "."
|
|
238
|
+
|
|
239
|
+
for obj in control_objectives:
|
|
240
|
+
if hasattr(obj, "otherId") and obj.otherId.startswith(base_pattern):
|
|
241
|
+
sub_parts.append(obj.otherId)
|
|
242
|
+
|
|
243
|
+
return sorted(sub_parts)
|
|
244
|
+
|
|
245
|
+
|
|
123
246
|
def transform_control(control: str) -> str:
|
|
124
247
|
"""
|
|
125
248
|
Function to parse the control string and transform it to the RegScale format
|
|
@@ -129,8 +252,8 @@ def transform_control(control: str) -> str:
|
|
|
129
252
|
:return: Transformed control ID to match RegScale control ID format
|
|
130
253
|
:rtype: str
|
|
131
254
|
"""
|
|
132
|
-
# Use regex to match the pattern and capture the parts
|
|
133
|
-
match = re.match(r"([A-Za-z]+)-(\d+)\s\(
|
|
255
|
+
# Use regex to match the pattern and capture the parts (handle extra spaces)
|
|
256
|
+
match = re.match(r"([A-Za-z]+)-(\d+)\s*\(\s*(\d+|[a-z])\s*\)", control)
|
|
134
257
|
if match:
|
|
135
258
|
control_name = match.group(1).lower()
|
|
136
259
|
control_number = match.group(2)
|
|
@@ -190,8 +313,8 @@ def gen_key(control_id: str):
|
|
|
190
313
|
# Match pattern: captures everything up to either:
|
|
191
314
|
# 1. The last (number) if it exists
|
|
192
315
|
# 2. The main control number if no enhancement exists
|
|
193
|
-
# And excludes any trailing (letter)
|
|
194
|
-
pattern = r"^((?:\w+-\d+(?:\(\d+\))?))(?:\([a-zA-Z]\))?$"
|
|
316
|
+
# And excludes any trailing (letter) - handles extra spaces like AC-6 ( 1 ) ( a )
|
|
317
|
+
pattern = r"^((?:\w+-\d+(?:\s*\(\s*\d+\s*\))?))(?:\s*\(\s*[a-zA-Z]\s*\))?$"
|
|
195
318
|
|
|
196
319
|
match = re.match(pattern, control_id)
|
|
197
320
|
if match:
|
|
@@ -221,6 +344,7 @@ def map_implementation_status(control_id: str, cis_data: dict) -> str:
|
|
|
221
344
|
logger.debug("Found %d CIS records for control %s", len(cis_records), control_id)
|
|
222
345
|
|
|
223
346
|
if not cis_records:
|
|
347
|
+
# Alerts if a control exists in regscale but is missing from CIS worksheet
|
|
224
348
|
logger.warning(f"No CIS records found for control {control_id}")
|
|
225
349
|
return status_ret
|
|
226
350
|
|
|
@@ -766,18 +890,12 @@ def gen_filtered_records(
|
|
|
766
890
|
sheet_data.values(),
|
|
767
891
|
)
|
|
768
892
|
else:
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
except StopIteration:
|
|
776
|
-
control_label = None
|
|
777
|
-
if control_label:
|
|
778
|
-
filtered_records = [r for r in sheet_data.values() if r["cis"]["regscale_control_id"] == control_label]
|
|
779
|
-
else:
|
|
780
|
-
filtered_records = []
|
|
893
|
+
# For rev4, convert OSCAL control ID to control label format and match against original control_id
|
|
894
|
+
# e.g., "ac-1" -> "ac-01", then match "AC-01 (a)", "AC-01 (b)", etc.
|
|
895
|
+
control_label = _convert_oscal_to_rev4_control_label(security_control.controlId)
|
|
896
|
+
filtered_records = filter(
|
|
897
|
+
lambda r: r["cis"]["regscale_control_id"].lower() == control_label.lower(), sheet_data.values()
|
|
898
|
+
)
|
|
781
899
|
|
|
782
900
|
return existing_objectives, filtered_records
|
|
783
901
|
|
|
@@ -801,11 +919,7 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
|
|
|
801
919
|
:rtype Tuple[List[str], Optional[ImplementationObjective]]
|
|
802
920
|
:returns A list of errors and the Implementation Objective if successful, otherwise None
|
|
803
921
|
"""
|
|
804
|
-
#
|
|
805
|
-
if not part_mapper_rev5.data:
|
|
806
|
-
part_mapper_rev5.load_fedramp_version_5_mapping()
|
|
807
|
-
if not part_mapper_rev4.data:
|
|
808
|
-
part_mapper_rev4.load_fedramp_version_4_mapping()
|
|
922
|
+
# No longer need to load JSON mappings - using smart algorithm only
|
|
809
923
|
|
|
810
924
|
errors = []
|
|
811
925
|
version = kwargs.get("version")
|
|
@@ -816,27 +930,21 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
|
|
|
816
930
|
existing_objectives: List[ImplementationObjective] = kwargs.get("existing_objectives")
|
|
817
931
|
mapped_objectives: List[ControlObjective] = []
|
|
818
932
|
result = None
|
|
819
|
-
parts = []
|
|
820
|
-
# Note: The Control ID from the CIS/CRM can be in non-standard formats, as compared to the example sheet on fedramp.
|
|
821
|
-
if version == "rev5":
|
|
822
|
-
key = record["cis"]["control_id"].replace(" ", "")
|
|
823
|
-
source = part_mapper_rev5.find_by_source(key)
|
|
824
|
-
else:
|
|
825
|
-
key = record["cis"]["control_id"]
|
|
826
|
-
source = part_mapper_rev4.find_by_source(key)
|
|
827
|
-
if parts := part_mapper_rev4.find_sub_parts(key):
|
|
828
|
-
for part in parts:
|
|
829
|
-
try:
|
|
830
|
-
if version == "rev5":
|
|
831
|
-
mapped_objectives.append(next(obj for obj in control_objectives if obj.name == part))
|
|
832
|
-
else:
|
|
833
|
-
mapped_objectives.append(next(obj for obj in control_objectives if obj.otherId == part))
|
|
834
|
-
except StopIteration:
|
|
835
|
-
errors.append(f"Unable to find part {part} for control {key}")
|
|
836
|
-
if not source and not parts:
|
|
837
|
-
errors.append(f"Unable to find source and part for control {key}")
|
|
838
933
|
|
|
839
|
-
|
|
934
|
+
# Get the control ID from the CIS/CRM record
|
|
935
|
+
key = record["cis"]["control_id"]
|
|
936
|
+
|
|
937
|
+
# Use smart algorithm to find mapping
|
|
938
|
+
source, parts, status = smart_find_by_source(key, control_objectives)
|
|
939
|
+
|
|
940
|
+
logger.debug(f"Smart mapping result for {key}: {status}")
|
|
941
|
+
|
|
942
|
+
# Add to errors list if status does not start with "Found"
|
|
943
|
+
if not status.startswith("Found"):
|
|
944
|
+
errors.append(f"{key}: {status}")
|
|
945
|
+
|
|
946
|
+
# Process exact match if found
|
|
947
|
+
if source:
|
|
840
948
|
try:
|
|
841
949
|
objective = next(
|
|
842
950
|
obj
|
|
@@ -848,6 +956,23 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
|
|
|
848
956
|
logger.debug(f"Missing Source: {source}")
|
|
849
957
|
errors.append(f"Unable to find objective for control {key} ({source})")
|
|
850
958
|
|
|
959
|
+
# Process sub-parts if found
|
|
960
|
+
if parts:
|
|
961
|
+
for part in parts:
|
|
962
|
+
try:
|
|
963
|
+
if version == "rev5":
|
|
964
|
+
mapped_objectives.append(next(obj for obj in control_objectives if obj.otherId == part))
|
|
965
|
+
else:
|
|
966
|
+
mapped_objectives.append(
|
|
967
|
+
next(obj for obj in control_objectives if obj.otherId == part or obj.name == part)
|
|
968
|
+
)
|
|
969
|
+
except StopIteration:
|
|
970
|
+
errors.append(f"Unable to find part {part} for control {key}")
|
|
971
|
+
|
|
972
|
+
# Report if no mapping found at all
|
|
973
|
+
if not source and not parts:
|
|
974
|
+
errors.append(f"Unable to find source and part for control {key}")
|
|
975
|
+
|
|
851
976
|
if mapped_objectives:
|
|
852
977
|
update_imp_objective(
|
|
853
978
|
leverage_auth_id=leveraged_auth_id,
|
|
@@ -1325,7 +1450,7 @@ def extract_control_name(control_string: str) -> str:
|
|
|
1325
1450
|
:return: The extracted control name
|
|
1326
1451
|
:rtype: str
|
|
1327
1452
|
"""
|
|
1328
|
-
pattern = r"^[A-Za-z]{2}-\d{1,3}(?:\(\d+\))?"
|
|
1453
|
+
pattern = r"^[A-Za-z]{2}-\d{1,3}(?:\s*\(\s*\d+\s*\))?"
|
|
1329
1454
|
match = re.match(pattern, control_string.upper())
|
|
1330
1455
|
return match.group() if match else ""
|
|
1331
1456
|
|
|
@@ -1338,8 +1463,8 @@ def rev_4_map(control_id: str) -> Optional[str]:
|
|
|
1338
1463
|
:return: The mapped control ID or None if not found
|
|
1339
1464
|
:rtype: Optional[str]
|
|
1340
1465
|
"""
|
|
1341
|
-
# Regex pattern to match different control ID formats
|
|
1342
|
-
pattern = r"^([A-Z]{2})-(\d{2})\s*(?:\((\d{2})\))?\s*(?:\(([a-z])\))?$"
|
|
1466
|
+
# Regex pattern to match different control ID formats - handles extra spaces like AC-6 ( 1 ) ( a )
|
|
1467
|
+
pattern = r"^([A-Z]{2})-(\d{2})\s*(?:\(\s*(\d{2})\s*\))?\s*(?:\(\s*([a-z])\s*\))?$"
|
|
1343
1468
|
|
|
1344
1469
|
match = re.match(pattern, control_id, re.IGNORECASE)
|
|
1345
1470
|
|
|
@@ -1549,7 +1674,7 @@ def copy_and_rename_file(file_path: Path, new_name: str) -> Path:
|
|
|
1549
1674
|
"""
|
|
1550
1675
|
Copy and rename a file.
|
|
1551
1676
|
"""
|
|
1552
|
-
temp_folder = Path(
|
|
1677
|
+
temp_folder = Path(gettempdir()) / "regscale"
|
|
1553
1678
|
temp_folder.mkdir(exist_ok=True) # Ensure directory exists
|
|
1554
1679
|
|
|
1555
1680
|
new_file_path = temp_folder / new_name
|
|
@@ -1610,10 +1735,9 @@ def parse_and_import_ciscrm(
|
|
|
1610
1735
|
|
|
1611
1736
|
if "5" in version:
|
|
1612
1737
|
version = "rev5"
|
|
1613
|
-
part_mapper_rev5.load_fedramp_version_5_mapping()
|
|
1614
1738
|
else:
|
|
1615
1739
|
version = "rev4"
|
|
1616
|
-
|
|
1740
|
+
# No longer loading JSON mappings - using smart algorithm only
|
|
1617
1741
|
# parse the instructions worksheet to get the csp name, system name, and other data
|
|
1618
1742
|
instructions_data = parse_instructions_worksheet(df=df, version=version) # type: ignore
|
|
1619
1743
|
|