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.

Files changed (29) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +3 -0
  3. regscale/core/app/utils/app_utils.py +31 -0
  4. regscale/integrations/commercial/jira.py +27 -5
  5. regscale/integrations/commercial/qualys/__init__.py +160 -60
  6. regscale/integrations/commercial/qualys/scanner.py +300 -39
  7. regscale/integrations/commercial/wizv2/async_client.py +4 -0
  8. regscale/integrations/commercial/wizv2/scanner.py +50 -24
  9. regscale/integrations/public/__init__.py +13 -0
  10. regscale/integrations/public/fedramp/fedramp_cis_crm.py +175 -51
  11. regscale/integrations/scanner_integration.py +513 -145
  12. regscale/models/integration_models/cisa_kev_data.json +34 -3
  13. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  14. regscale/models/regscale_models/__init__.py +2 -0
  15. regscale/models/regscale_models/catalog.py +1 -1
  16. regscale/models/regscale_models/control_implementation.py +8 -8
  17. regscale/models/regscale_models/form_field_value.py +5 -3
  18. regscale/models/regscale_models/inheritance.py +44 -0
  19. regscale/regscale.py +2 -0
  20. {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/METADATA +1 -1
  21. {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/RECORD +26 -28
  22. tests/regscale/models/test_tenable_integrations.py +811 -105
  23. regscale/integrations/public/fedramp/mappings/fedramp_r4_parts.json +0 -7388
  24. regscale/integrations/public/fedramp/mappings/fedramp_r5_parts.json +0 -9605
  25. regscale/integrations/public/fedramp/parts_mapper.py +0 -107
  26. {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/LICENSE +0 -0
  27. {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/WHEEL +0 -0
  28. {regscale_cli-6.21.2.2.dist-info → regscale_cli-6.22.0.0.dist-info}/entry_points.txt +0 -0
  29. {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\((\d+|[a-z])\)", control)
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
- try:
770
- control_label = next(
771
- dat
772
- for dat in part_mapper_rev4.data
773
- if dat.get("Oscal Control ID") == security_control.controlId.lower()
774
- ).get("CONTROLLABEL")
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
- # for pytest
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
- if source and not parts:
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(tempfile.gettempdir()) / "regscale"
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
- part_mapper_rev4.load_fedramp_version_4_mapping()
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