regscale-cli 6.16.3.0__py3-none-any.whl → 6.16.4.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 (42) hide show
  1. regscale/__init__.py +1 -1
  2. regscale/core/app/internal/control_editor.py +26 -2
  3. regscale/core/app/internal/model_editor.py +39 -26
  4. regscale/integrations/commercial/grype/scanner.py +37 -29
  5. regscale/integrations/commercial/opentext/commands.py +2 -0
  6. regscale/integrations/commercial/opentext/scanner.py +45 -31
  7. regscale/integrations/commercial/qualys.py +3 -1
  8. regscale/integrations/commercial/sicura/commands.py +9 -14
  9. regscale/integrations/commercial/tenablev2/click.py +25 -13
  10. regscale/integrations/commercial/tenablev2/scanner.py +12 -3
  11. regscale/integrations/commercial/trivy/scanner.py +14 -6
  12. regscale/integrations/commercial/wizv2/click.py +15 -37
  13. regscale/integrations/jsonl_scanner_integration.py +120 -16
  14. regscale/integrations/public/fedramp/click.py +8 -8
  15. regscale/integrations/public/fedramp/fedramp_cis_crm.py +499 -106
  16. regscale/integrations/public/fedramp/ssp_logger.py +2 -9
  17. regscale/integrations/scanner_integration.py +14 -9
  18. regscale/models/integration_models/cisa_kev_data.json +39 -8
  19. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  20. regscale/models/integration_models/tenable_models/integration.py +23 -3
  21. regscale/models/regscale_models/control_implementation.py +18 -0
  22. regscale/models/regscale_models/control_objective.py +2 -1
  23. regscale/models/regscale_models/facility.py +10 -26
  24. regscale/models/regscale_models/functional_roles.py +38 -0
  25. regscale/models/regscale_models/issue.py +3 -1
  26. regscale/models/regscale_models/parameter.py +21 -3
  27. regscale/models/regscale_models/profile.py +22 -0
  28. regscale/models/regscale_models/profile_mapping.py +48 -3
  29. regscale/models/regscale_models/regscale_model.py +2 -0
  30. regscale/models/regscale_models/risk.py +38 -30
  31. regscale/models/regscale_models/security_plan.py +1 -0
  32. regscale/models/regscale_models/supply_chain.py +1 -1
  33. regscale/models/regscale_models/user.py +16 -2
  34. regscale/utils/threading/__init__.py +1 -0
  35. regscale/utils/threading/threadsafe_list.py +10 -0
  36. regscale/utils/threading/threadsafe_set.py +116 -0
  37. {regscale_cli-6.16.3.0.dist-info → regscale_cli-6.16.4.0.dist-info}/METADATA +1 -1
  38. {regscale_cli-6.16.3.0.dist-info → regscale_cli-6.16.4.0.dist-info}/RECORD +42 -40
  39. {regscale_cli-6.16.3.0.dist-info → regscale_cli-6.16.4.0.dist-info}/LICENSE +0 -0
  40. {regscale_cli-6.16.3.0.dist-info → regscale_cli-6.16.4.0.dist-info}/WHEEL +0 -0
  41. {regscale_cli-6.16.3.0.dist-info → regscale_cli-6.16.4.0.dist-info}/entry_points.txt +0 -0
  42. {regscale_cli-6.16.3.0.dist-info → regscale_cli-6.16.4.0.dist-info}/top_level.txt +0 -0
@@ -2,23 +2,27 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  # pylint: disable=C0415
4
4
  """standard python imports"""
5
+ from __future__ import annotations
6
+
5
7
  import json
6
8
  import math
7
9
  import re
8
10
  from collections import Counter
9
11
  from concurrent.futures import as_completed
10
12
  from concurrent.futures.thread import ThreadPoolExecutor
11
- from typing import Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import urljoin
13
+ from threading import Thread
14
+ from types import ModuleType
15
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Tuple, TypeVar
13
16
 
14
17
  import click
15
18
 
16
19
  from regscale.core.app.api import Api
20
+ from regscale.core.app.utils.api_handler import APIInsertionError, APIUpdateError
17
21
  from regscale.core.app.utils.app_utils import create_progress_object, error_and_exit, get_current_datetime
18
22
  from regscale.core.utils.graphql import GraphQLQuery
19
23
  from regscale.integrations.public.fedramp.parts_mapper import PartMapper
20
24
  from regscale.integrations.public.fedramp.ssp_logger import SSPLogger
21
- from regscale.models import ControlObjective, ImplementationObjective
25
+ from regscale.models import ControlObjective, ImplementationObjective, Parameter, Profile
22
26
  from regscale.models.regscale_models import (
23
27
  ControlImplementation,
24
28
  File,
@@ -26,7 +30,17 @@ from regscale.models.regscale_models import (
26
30
  SecurityControl,
27
31
  SecurityPlan,
28
32
  )
33
+ from regscale.models.regscale_models.compliance_settings import ComplianceSettings
29
34
  from regscale.models.regscale_models.control_implementation import ControlImplementationStatus
35
+ from regscale.utils.threading import ThreadSafeDict, ThreadSafeSet
36
+
37
+ # For type annotations only
38
+ if TYPE_CHECKING:
39
+ import pandas as pd
40
+
41
+ from functools import lru_cache
42
+
43
+ T = TypeVar("T")
30
44
 
31
45
  logger = SSPLogger()
32
46
  part_mapper_rev5 = PartMapper()
@@ -49,6 +63,9 @@ IMPACT_LEVEL = "Impact Level"
49
63
  SYSTEM_NAME = "System Name"
50
64
  CSP = "CSP"
51
65
 
66
+ EXISTING_IMPLEMENTATIONS: ThreadSafeDict[int, ControlImplementation] = ThreadSafeDict()
67
+ UPDATED_IMPLEMENTATION_OBJECTIVES: ThreadSafeSet[ImplementationObjective] = ThreadSafeSet()
68
+
52
69
  STATUS_MAPPING = {
53
70
  "Implemented": ControlImplementationStatus.Implemented,
54
71
  PARTIALLY_IMPLEMENTED: ControlImplementationStatus.PartiallyImplemented,
@@ -76,6 +93,20 @@ RESPONSIBILITY_MAP = {
76
93
  "bShared": "Shared",
77
94
  "bInherited": "Inherited",
78
95
  }
96
+ REGSCALE_SSP_ID: int = 0
97
+
98
+
99
+ @lru_cache(maxsize=1)
100
+ def get_pandas() -> ModuleType:
101
+ """
102
+ Lazily import pandas only once when needed
103
+
104
+ :return: The pandas module
105
+ :rtype: ModuleType
106
+ """
107
+ import pandas as pd
108
+
109
+ return pd
79
110
 
80
111
 
81
112
  def transform_control(control: str) -> str:
@@ -280,17 +311,17 @@ def update_imp_objective(
280
311
  imp: ControlImplementation,
281
312
  objectives: List[ControlObjective],
282
313
  record: dict,
283
- ) -> Optional[ImplementationObjective]:
314
+ ) -> None:
284
315
  """
285
- Update the control objective with the given record data.
316
+ Update the control objectives with the given record data.
286
317
 
287
318
  :param int leverage_auth_id: The leveraged authorization ID
288
319
  :param List[ImplementationObjective] existing_imp_obj: The existing implementation objective
289
320
  :param ControlImplementation imp: The control implementation to update
290
321
  :param List[ControlObjective] objectives: The control objective to update
291
322
  :param dict record: The CIS/CRM record data to update the objective with
292
- :rtype: Optional[ImplementationObjective]
293
- :return: The updated or created implementation objective
323
+ :rtype: None
324
+ :return: None
294
325
  """
295
326
  status_map = {
296
327
  "Implemented": ControlImplementationStatus.Implemented.value,
@@ -312,32 +343,49 @@ def update_imp_objective(
312
343
 
313
344
  cis_record = record.get("cis", {})
314
345
  crm_record = record.get("crm", {})
315
- responsibility = RESPONSIBILITY_MAP.get(
316
- cis_record.get("control_origination", ""), ControlImplementationStatus.NA.value
317
- )
346
+ # There could be multiples, take the first one as regscale will not allow multiples at the objective level.
347
+ control_originations = cis_record.get("control_origination", "").split(",")
348
+ for ix, control_origination in enumerate(control_originations):
349
+ control_originations[ix] = control_origination.strip()
350
+
351
+ try:
352
+ responsibility = RESPONSIBILITY_MAP.get(
353
+ next(origin for origin in control_originations), SERVICE_PROVIDER_CORPORATE
354
+ )
355
+ except StopIteration:
356
+ responsibility = SERVICE_PROVIDER_CORPORATE
357
+
318
358
  customer_responsibility = clean_customer_responsibility(
319
359
  crm_record.get("specific_inheritance_and_customer_agency_csp_responsibilities")
320
360
  )
321
- ret_objective = None
322
361
  existing_pairs = {(obj.objectiveId, obj.implementationId) for obj in existing_imp_obj}
362
+ responsibility = responsibility_map.get(responsibility, responsibility)
323
363
  for objective in objectives:
324
364
  current_pair = (objective.id, imp.id)
325
365
  if current_pair not in existing_pairs:
326
366
  imp_obj = ImplementationObjective(
327
367
  id=0,
328
368
  uuid="",
329
- inherited=crm_record.get("can_be_inherited_from_csp") == "Yes",
369
+ inherited=crm_record.get("can_be_inherited_from_csp") in ["Yes", "Partial"],
330
370
  implementationId=imp.id,
331
371
  status=status_map.get(cis_record.get("implementation_status", NOT_IMPLEMENTED), NOT_IMPLEMENTED),
332
372
  objectiveId=objective.id,
333
373
  notes=objective.name,
334
374
  securityControlId=objective.securityControlId,
335
- responsibility=responsibility_map.get(responsibility, responsibility),
375
+ securityPlanId=REGSCALE_SSP_ID,
376
+ responsibility=responsibility,
336
377
  cloudResponsibility=customer_responsibility,
337
378
  customerResponsibility=customer_responsibility,
338
379
  authorizationId=leverage_auth_id,
380
+ parentObjectiveId=objective.parentObjectiveId,
381
+ )
382
+ logger.debug(
383
+ "Creating new Implementation Objective for Control %s with status: %s responsibility: %s",
384
+ imp_obj.securityControlId,
385
+ imp_obj.status,
386
+ imp_obj.responsibility,
339
387
  )
340
- ret_objective = imp_obj.create()
388
+ UPDATED_IMPLEMENTATION_OBJECTIVES.add(imp_obj)
341
389
  else:
342
390
  # NOTE: Don't overwrite the responsibility text and only append.
343
391
  ex_obj = next((obj for obj in existing_imp_obj if obj.objectiveId == objective.id), None)
@@ -355,21 +403,19 @@ def update_imp_objective(
355
403
  )
356
404
  if ex_obj.cloudResponsibility:
357
405
  ex_obj.cloudResponsibility = (
358
- seperator.join([ex_obj.cloudResponsibility, responsibility])
406
+ seperator.join([ex_obj.cloudResponsibility, customer_responsibility])
359
407
  if ex_obj.cloudResponsibility != responsibility
360
408
  else ex_obj.cloudResponsibility
361
409
  )
362
410
  if ex_obj.customerResponsibility:
363
411
  ex_obj.customerResponsibility = (
364
- seperator.join([ex_obj.customerResponsibility, responsibility])
412
+ seperator.join([ex_obj.customerResponsibility, customer_responsibility])
365
413
  if ex_obj.cloudResponsibility != responsibility
366
414
  else ex_obj.customerResponsibility
367
415
  )
368
416
  except TypeError:
369
417
  logger.warning(f"Failed to update responsibility on Implementation Objective #{ex_obj.id}")
370
- ret_objective = ex_obj.save()
371
-
372
- return ret_objective
418
+ UPDATED_IMPLEMENTATION_OBJECTIVES.add(ex_obj)
373
419
 
374
420
 
375
421
  def parse_control_details(
@@ -456,62 +502,62 @@ def fetch_and_update_imps(
456
502
  :rtype: Optional[ControlImplementation]
457
503
  """
458
504
  # get the control and control implementation objects
459
- regscale_control = SecurityControl.get_object(control["scId"])
460
- regscale_control_imp = ControlImplementation.get_object(control["id"])
505
+ regscale_control = SecurityControl.get_object(control.controlID)
506
+ if not regscale_control:
507
+ api.logger.error(f"Failed to fetch control with ID {control['scId']}")
508
+ return None
461
509
 
462
- if not regscale_control or not regscale_control_imp:
463
- api.logger.error("Failed to fetch control or control implementation")
464
- return regscale_control_imp
510
+ control_id = regscale_control.controlId if regscale_control else ""
511
+ security_control_id = regscale_control.id if regscale_control else 0
512
+ regscale_control_imp = EXISTING_IMPLEMENTATIONS.get(security_control_id)
513
+
514
+ if not regscale_control_imp:
515
+ api.logger.error(f"Failed to find control implementation for control ID {control_id}")
516
+ return None
465
517
 
466
518
  updated_control = parse_control_details(
467
519
  version=version, control_imp=regscale_control_imp, control=regscale_control, cis_data=cis_data
468
520
  )
521
+
522
+ # Find the index of the old implementation and replace it with the updated one
523
+ EXISTING_IMPLEMENTATIONS[updated_control.controlID] = updated_control
524
+
469
525
  return updated_control
470
526
 
471
527
 
472
- def get_all_imps(api: Api, ssp_id: int, cis_data: dict, version: Literal["rev4", "rev5"]) -> list:
528
+ def get_all_imps(api: Api, cis_data: dict, version: Literal["rev4", "rev5"]) -> None:
473
529
  """
474
530
  Function to retrieve control implementations and their objectives from RegScale
475
531
 
476
532
  :param Api api: The RegScale API object
477
- :param int ssp_id: The SSP ID
478
533
  :param dict cis_data: The data from the CIS worksheet
479
534
  :param Literal["rev4", "rev5"] version: The version of the workbook
480
- :return: List of updated control implementations
481
- :rtype: list
535
+ :return: None
536
+ :rtype: None
482
537
  """
483
538
  from requests import RequestException
484
539
 
485
- updated_controls = []
486
- url = urljoin(api.config["domain"], f"/api/controlImplementation/getSCListByPlan/{ssp_id}")
487
- response = api.get(url)
488
-
489
- if response.status_code == 404:
490
- api.logger.warning(f"SSP with ID {ssp_id} has no controls.")
491
- return updated_controls
492
-
493
540
  # Check if the response is successful
494
- if response.status_code == 200:
495
- ssp_controls = response.json()
541
+ if EXISTING_IMPLEMENTATIONS:
496
542
  # Get Control Implementations For SSP
497
543
  fetching_imps = progress.add_task(
498
- f"[magenta]Fetching & updating {len(ssp_controls)} implementation(s)...", total=len(ssp_controls)
544
+ f"[magenta]Updating {len(EXISTING_IMPLEMENTATIONS)} implementation(s)...",
545
+ total=len(EXISTING_IMPLEMENTATIONS),
499
546
  )
500
547
  with ThreadPoolExecutor(max_workers=50) as executor:
501
548
  futures = [
502
- executor.submit(fetch_and_update_imps, control, api, cis_data, version) for control in ssp_controls
549
+ executor.submit(fetch_and_update_imps, control, api, cis_data, version)
550
+ for control in EXISTING_IMPLEMENTATIONS.values()
503
551
  ]
552
+
553
+ # Just wait for all tasks to complete
504
554
  for future in as_completed(futures):
505
- progress.update(fetching_imps, advance=1)
506
555
  try:
507
- controls = future.result()
508
- updated_controls.append(controls)
509
- except (RequestException, TimeoutError) as ex:
510
- api.logger.error(f"Error fetching control implementations: {ex}")
511
- else:
512
- api.logger.error(f"Failed to fetch controls: {response.status_code}: {response.reason}")
513
-
514
- return updated_controls
556
+ # Only call result() to propagate any exceptions
557
+ future.result()
558
+ progress.update(fetching_imps, advance=1)
559
+ except Exception as e:
560
+ logger.error(f"Error updating implementation: {e}")
515
561
 
516
562
 
517
563
  def get_all_control_objectives(imps: List[ControlImplementation]) -> List[ControlObjective]:
@@ -543,7 +589,6 @@ def update_all_objectives(
543
589
  leveraged_auth_id: int,
544
590
  cis_data: Dict[str, Dict[str, str]],
545
591
  crm_data: Dict[str, Dict[str, str]],
546
- control_implementations: List[ControlImplementation],
547
592
  version: Literal["rev4", "rev5"],
548
593
  ) -> set:
549
594
  """
@@ -553,25 +598,27 @@ def update_all_objectives(
553
598
  :param int leveraged_auth_id: The leveraged authorization ID
554
599
  :param Dict[str, Dict[str, str]] cis_data: The CIS data to update from
555
600
  :param Dict[str, Dict[str, str]] crm_data: The CRM data to update from
556
- :param List[ControlImplementation] control_implementations: The control implementations to update
557
601
  :param Literal["rev4", "rev5"] version: The version of the workbook
558
602
  :return: A set of errors, if any
559
603
  :rtype: set
560
604
  """
561
- all_control_objectives = get_all_control_objectives(imps=control_implementations)
605
+
606
+ all_control_objectives = get_all_control_objectives(imps=EXISTING_IMPLEMENTATIONS.values())
562
607
  error_set = set()
563
- task = progress.add_task("[cyan]Processing control objectives...", total=len(control_implementations))
608
+ process_task = progress.add_task(
609
+ "[cyan]Processing control objectives...", total=len(EXISTING_IMPLEMENTATIONS.values())
610
+ )
564
611
  # Create a combined dataset for easier access
565
612
  combined_data = {key: {"cis": cis_data[key], "crm": crm_data.get(key, {})} for key in cis_data}
566
613
 
567
614
  # Process implementations in parallel
568
- with ThreadPoolExecutor(max_workers=50) as executor:
615
+ with ThreadPoolExecutor(max_workers=30) as executor:
569
616
  # Submit all tasks
570
617
  future_to_control = {
571
618
  executor.submit(
572
619
  process_implementation, leveraged_auth_id, imp, combined_data, version, all_control_objectives
573
620
  ): imp
574
- for imp in control_implementations
621
+ for imp in EXISTING_IMPLEMENTATIONS.values()
575
622
  }
576
623
 
577
624
  # Process results as they complete
@@ -581,8 +628,18 @@ def update_all_objectives(
581
628
  error_lst = result[0]
582
629
  for inf in error_lst:
583
630
  error_set.add(inf)
584
- progress.update(task, advance=1)
585
-
631
+ progress.update(process_task, advance=1)
632
+ save_task = progress.add_task("[yellow]Saving control objectives...", total=len(UPDATED_IMPLEMENTATION_OBJECTIVES))
633
+ # Process implementations in parallel
634
+ # Note, not using threadpool executor here due to phantom 500 errors. This is a workaround
635
+ for obj in UPDATED_IMPLEMENTATION_OBJECTIVES:
636
+ try:
637
+ obj.create_or_update()
638
+ progress.update(save_task, advance=1)
639
+ except APIInsertionError as e:
640
+ error_set.add(f"Failed to create Implementation Objective: {e}")
641
+ except APIUpdateError as e:
642
+ error_set.add(f"Failed to update Implementation Objective: {e}")
586
643
  return error_set
587
644
 
588
645
 
@@ -673,7 +730,9 @@ def gen_filtered_records(
673
730
  else:
674
731
  try:
675
732
  control_label = next(
676
- dat for dat in part_mapper_rev4.data if dat.get("Oscal Control ID") == security_control.controlId
733
+ dat
734
+ for dat in part_mapper_rev4.data
735
+ if dat.get("Oscal Control ID") == security_control.controlId.lower()
677
736
  ).get("CONTROLLABEL")
678
737
  except StopIteration:
679
738
  control_label = None
@@ -714,15 +773,20 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
714
773
  mapped_objectives: List[ControlObjective] = []
715
774
  result = None
716
775
  parts = []
717
- key = record["cis"]["control_id"]
776
+ # Note: The Control ID from the CIS/CRM can be in non-standard formats, as compared to the example sheet on fedramp.
718
777
  if version == "rev5":
778
+ key = record["cis"]["control_id"].replace(" ", "")
719
779
  source = part_mapper_rev5.find_by_source(key)
720
780
  else:
781
+ key = record["cis"]["control_id"]
721
782
  source = part_mapper_rev4.find_by_source(key)
722
783
  if parts := part_mapper_rev4.find_sub_parts(key):
723
784
  for part in parts:
724
785
  try:
725
- mapped_objectives.append(next(obj for obj in control_objectives if obj.name == part))
786
+ if version == "rev5":
787
+ mapped_objectives.append(next(obj for obj in control_objectives if obj.name == part))
788
+ else:
789
+ mapped_objectives.append(next(obj for obj in control_objectives if obj.otherId == part))
726
790
  except StopIteration:
727
791
  errors.append(f"Unable to find part {part} for control {key}")
728
792
  if not source and not parts:
@@ -733,7 +797,7 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
733
797
  objective = next(
734
798
  obj
735
799
  for obj in control_objectives
736
- if obj.otherId == source and version == "rev5" or obj.name == source and version == "rev4"
800
+ if (obj.otherId == source and version in ["rev5", "rev4"]) or (obj.name == source and version == "rev4")
737
801
  )
738
802
  mapped_objectives.append(objective)
739
803
  except StopIteration:
@@ -741,7 +805,7 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
741
805
  errors.append(f"Unable to find objective for control {key} ({source})")
742
806
 
743
807
  if mapped_objectives:
744
- result = update_imp_objective(
808
+ update_imp_objective(
745
809
  leverage_auth_id=leveraged_auth_id,
746
810
  existing_imp_obj=existing_objectives,
747
811
  imp=implementation,
@@ -762,43 +826,58 @@ def parse_crm_worksheet(file_path: click.Path, crm_sheet_name: str, version: Lit
762
826
  :return: Formatted CRM content
763
827
  :rtype: dict
764
828
  """
829
+ pd = get_pandas()
830
+ logger.info("Parsing CRM worksheet...")
765
831
  formatted_crm = {}
766
832
 
767
833
  if not crm_sheet_name:
768
834
  return formatted_crm
769
- import pandas as pd # Optimize import performance
770
835
 
771
- if version == "rev5":
772
- skip_rows = 2
773
- else:
774
- skip_rows = 3
836
+ # Value for rev4
837
+ skip_rows = 3
775
838
 
776
- data = pd.read_excel(
839
+ original_data = pd.read_excel(
777
840
  str(file_path),
778
841
  sheet_name=crm_sheet_name,
779
- skiprows=skip_rows,
780
- usecols=[
781
- CONTROL_ID,
782
- "Can Be Inherited from CSP",
783
- "Specific Inheritance and Customer Agency/CSP Responsibilities",
784
- ],
785
842
  )
786
843
 
844
+ # find index of row where the first column == Control ID
845
+ skip_rows = determine_skip_row(original_df=original_data, text_to_find=CONTROL_ID, original_skip=skip_rows)
846
+
847
+ logger.debug(f"Skipping {skip_rows} rows in CRM worksheet")
848
+
849
+ # only use thse coloumns
850
+ usecols = [
851
+ CONTROL_ID,
852
+ "Can Be Inherited from CSP",
853
+ "Specific Inheritance and Customer Agency/CSP Responsibilities",
854
+ ]
855
+
856
+ try:
857
+ # Reindex the dataframe and skip some rows
858
+ data = original_data.iloc[skip_rows:].reset_index(drop=True)
859
+ data.columns = usecols
860
+ except KeyError as e:
861
+ error_and_exit(f"KeyError: {e} - One or more columns specified in usecols are not found in the dataframe.")
862
+ except Exception as e:
863
+ error_and_exit(f"An error occurred: {e}")
787
864
  # Filter rows where "Can Be Inherited from CSP" is not equal to "No"
788
865
  exclude_no = data[data[CAN_BE_INHERITED_CSP] != "No"]
789
866
 
790
867
  # Iterate through each row and add to the dictionary
791
868
  for _, row in exclude_no.iterrows():
792
869
  control_id = row[CONTROL_ID]
870
+ if version == "rev5":
871
+ control_id = row[CONTROL_ID].replace(" ", "")
793
872
 
794
873
  # Convert camel case to snake case, remove special characters, and convert to lowercase
795
874
  clean_control_id = re.sub(r"\W+", "", control_id)
796
875
  clean_control_id = re.sub("([a-z0-9])([A-Z])", r"\1_\2", clean_control_id).lower()
797
876
 
798
877
  # Use clean_control_id as the key to avoid overwriting
799
- formatted_crm[clean_control_id] = {
800
- "control_id": clean_control_id,
801
- "control_id_original": control_id,
878
+ formatted_crm[control_id] = {
879
+ "control_id": control_id,
880
+ "clean_control_id": clean_control_id,
802
881
  "regscale_control_id": transform_control(control_id),
803
882
  "can_be_inherited_from_csp": row[CAN_BE_INHERITED_CSP],
804
883
  "specific_inheritance_and_customer_agency_csp_responsibilities": row[
@@ -818,10 +897,15 @@ def parse_cis_worksheet(file_path: click.Path, cis_sheet_name: str) -> dict:
818
897
  :return: Formatted CIS content
819
898
  :rtype: dict
820
899
  """
821
- import pandas as pd # Optimize import performance
822
-
900
+ pd = get_pandas()
901
+ logger.info("Parsing CIS worksheet...")
902
+ skip_rows = 2
823
903
  # Parse the worksheet named 'CIS GovCloud U.S.+DoD (H)', skipping the initial rows
824
- cis_df = pd.read_excel(file_path, sheet_name=cis_sheet_name, skiprows=2)
904
+ original_cis = pd.read_excel(file_path, sheet_name=cis_sheet_name)
905
+
906
+ skip_rows = determine_skip_row(original_df=original_cis, text_to_find=CONTROL_ID, original_skip=skip_rows)
907
+
908
+ cis_df = original_cis.iloc[skip_rows:].reset_index(drop=True)
825
909
 
826
910
  # Set the appropriate headers
827
911
  cis_df.columns = cis_df.iloc[0]
@@ -918,27 +1002,81 @@ def parse_cis_worksheet(file_path: click.Path, cis_sheet_name: str) -> dict:
918
1002
  return {result["control_id"]: result for result in results}
919
1003
 
920
1004
 
1005
+ def determine_skip_row(original_df: "pd.DataFrame", text_to_find: str, original_skip: int):
1006
+ """
1007
+ Function to determine the row to skip when parsing a worksheet
1008
+
1009
+ :param pd.DataFrame original_df: The original dataframe to search
1010
+ :param str text_to_find: The text to find
1011
+ :param int original_skip: The original row to skip
1012
+ :return: The row to skip
1013
+ :rtype: int
1014
+ """
1015
+ skip_rows = original_skip
1016
+ for idx, row in original_df.iterrows():
1017
+ if row.iloc[0] == text_to_find:
1018
+ skip_rows = idx + 1
1019
+ break
1020
+ return skip_rows
1021
+
1022
+
1023
+ def _drop_rows_nan(instructions_df: "pd.DataFrame") -> "pd.DataFrame":
1024
+ """
1025
+ Drop any row with nan and every row after it
1026
+
1027
+ :param pd.DataFrame instructions_df: The instructions dataframe to process
1028
+ :return: The processed dataframe
1029
+ :rtype: pd.DataFrame
1030
+ """
1031
+ # Find the first row containing any NaN value
1032
+ first_nan_index = None
1033
+ for i in range(len(instructions_df)):
1034
+ if instructions_df.iloc[i].isna().any():
1035
+ first_nan_index = i
1036
+ break
1037
+
1038
+ # If a row with NaN is found, keep only rows before it
1039
+ if first_nan_index is not None:
1040
+ instructions_df = instructions_df.iloc[:first_nan_index]
1041
+ else:
1042
+ # Otherwise, just drop any rows with NaN values as before
1043
+ instructions_df = instructions_df.dropna()
1044
+ return instructions_df
1045
+
1046
+
921
1047
  def parse_instructions_worksheet(
922
- file_path: click.Path, version: Literal["rev4", "rev5"], instructions_sheet_name: str = "Instructions"
1048
+ df: "pd.DataFrame",
1049
+ version: Literal["rev4", "rev5"],
1050
+ instructions_sheet_name: str = "Instructions",
923
1051
  ) -> list[dict]:
924
1052
  """
925
1053
  Function to parse the instructions sheet from the FedRAMP Rev5 CIS/CRM workbook
926
1054
 
927
- :param click.Path file_path: The file path to the FedRAMP CIS CRM workbook
1055
+ :param pd.DataFrame df: The dataframe to parse
928
1056
  :param Literal["rev4", "rev5"] version: The version of the FedRAMP CIS CRM workbook
929
1057
  :param str instructions_sheet_name: The name of the instructions sheet to parse, defaults to "Instructions"
930
1058
  :return: List of formatted instructions content as a dictionary
931
1059
  :rtype: list[dict]
932
1060
  """
933
- import pandas as pd # Optimize import performance
934
-
935
- instructions_df = pd.read_excel(str(file_path), sheet_name=instructions_sheet_name, skiprows=2)
1061
+ pd = get_pandas()
1062
+ df = df[instructions_sheet_name].iloc[2:]
1063
+ instructions_df = df.dropna(axis=1, how="all")
936
1064
 
937
1065
  if version == "rev5":
1066
+ relevant_columns = [CSP, SYSTEM_NAME, "System Identifier", IMPACT_LEVEL]
938
1067
  # Set the appropriate headers
939
- instructions_df.columns = instructions_df.iloc[0]
940
- instructions_df = instructions_df[1:]
941
- relevant_columns = [SYSTEM_NAME, CSP, "System Identifier", IMPACT_LEVEL]
1068
+ # Find the row with the CSP column e.g. "System Name (CSP to complete all cells)"
1069
+ for index in range(len(instructions_df)):
1070
+ # Check if CSP is in the non-NaN values of this row
1071
+ row_values = [val for val in instructions_df.iloc[index].values if not pd.isna(val)]
1072
+ if CSP in row_values:
1073
+ # Keep only columns that have non-NaN values in this row
1074
+ non_nan_cols = instructions_df.columns[~instructions_df.iloc[index].isna()]
1075
+ instructions_df = instructions_df.loc[:, non_nan_cols]
1076
+ instructions_df.columns = relevant_columns
1077
+ instructions_df = instructions_df[index + 1 :]
1078
+ break
1079
+
942
1080
  else:
943
1081
  for index in range(len(instructions_df)):
944
1082
  if CSP in instructions_df.iloc[index].values:
@@ -949,6 +1087,9 @@ def parse_instructions_worksheet(
949
1087
  relevant_columns = [SYSTEM_NAME, CSP, IMPACT_LEVEL]
950
1088
  try:
951
1089
  instructions_df = instructions_df[relevant_columns]
1090
+ # drop any row with nan
1091
+ instructions_df = _drop_rows_nan(instructions_df)
1092
+
952
1093
  except KeyError:
953
1094
  error_and_exit(
954
1095
  f"Unable to find the relevant columns in the Instructions worksheet. Do you have the correct "
@@ -959,8 +1100,91 @@ def parse_instructions_worksheet(
959
1100
  return instructions_df.to_dict(orient="records")
960
1101
 
961
1102
 
1103
+ def update_responsiblity_text():
1104
+ """
1105
+ Update the implementation responsibility texts from the objective data
1106
+ """
1107
+ with ThreadPoolExecutor() as executor:
1108
+ executor.map(_update_imp_responsibility, EXISTING_IMPLEMENTATIONS.values())
1109
+
1110
+
1111
+ def _update_imp_responsibility(imp: ControlImplementation):
1112
+ """
1113
+ Update the implementation responsibility text for a given implementation
1114
+
1115
+ :param ControlImplementation imp: The implementation to update
1116
+ :rtype: None
1117
+ :return: None
1118
+ """
1119
+ # Get relevant objectives and sort them
1120
+ objs = _get_sorted_objectives(imp.id)
1121
+
1122
+ # Generate formatted responsibility texts
1123
+ customer_text = _format_responsibility_text(objs, "customerResponsibility")
1124
+ cloud_text = _format_responsibility_text(objs, "cloudResponsibility")
1125
+
1126
+ # Update implementation if we have content
1127
+ if customer_text or cloud_text:
1128
+ _save_implementation_text(imp, customer_text, cloud_text)
1129
+
1130
+
1131
+ def _get_sorted_objectives(imp_id: int) -> List[ImplementationObjective]:
1132
+ """
1133
+ Get relevant objectives sorted by notes field
1134
+
1135
+ :param int imp_id: The implementation ID to filter objectives by
1136
+ :rtype: list
1137
+ :return: Sorted list of objectives
1138
+ :rtype: List[ImplementationObjective]
1139
+ """
1140
+ objs = [obj for obj in UPDATED_IMPLEMENTATION_OBJECTIVES if obj.implementationId == imp_id]
1141
+ return sorted(objs, key=lambda x: x.notes)
1142
+
1143
+
1144
+ def _format_responsibility_text(objs: list, resp_attr: str) -> str:
1145
+ """
1146
+ Format responsibility text for the given objects and attribute
1147
+
1148
+ :param list objs: The list of objects to format
1149
+ :param str resp_attr: The attribute to format
1150
+ :rtype: str
1151
+ :return: Formatted text
1152
+ """
1153
+ text = ""
1154
+ multi_part = len(objs) > 1
1155
+
1156
+ for obj in objs:
1157
+ resp_text = getattr(obj, resp_attr, "")
1158
+ if resp_text:
1159
+ if multi_part:
1160
+ text += f"<p>part: {obj.notes}</p>"
1161
+ text += f"<p>{resp_text}</p>"
1162
+
1163
+ return text
1164
+
1165
+
1166
+ def _save_implementation_text(imp: ControlImplementation, customer_text: str, cloud_text: str):
1167
+ """
1168
+ Save the implementation texts and update parameters
1169
+
1170
+ :param ControlImplementation imp: The implementation to save
1171
+ :param str customer_text: The customer responsibility text
1172
+ :param str cloud_text: The cloud responsibility text
1173
+ :rtype: None
1174
+ :return: None
1175
+ """
1176
+ imp.customerImplementation = customer_text
1177
+ imp.cloudImplementation = cloud_text
1178
+
1179
+ # Update parameters in background thread
1180
+ _spin_off_thread(parameter_merge, imp.id, imp.controlID)
1181
+
1182
+ # Save implementation changes
1183
+ imp.save()
1184
+
1185
+
962
1186
  def parse_and_map_data(
963
- leveraged_auth_id: int, api: Api, ssp_id: int, cis_data: dict, crm_data: dict, version: Literal["rev5", "rev4"]
1187
+ leveraged_auth_id: int, api: Api, cis_data: dict, crm_data: dict, version: Literal["rev5", "rev4"]
964
1188
  ) -> None:
965
1189
  """
966
1190
  Function to parse and map data from RegScale and the workbook.
@@ -974,14 +1198,14 @@ def parse_and_map_data(
974
1198
  :rtype: None
975
1199
  """
976
1200
  with progress:
977
- implementations = get_all_imps(api=api, ssp_id=ssp_id, cis_data=cis_data, version=version)
1201
+ get_all_imps(api=api, cis_data=cis_data, version=version)
978
1202
  error_set = update_all_objectives(
979
1203
  leveraged_auth_id=leveraged_auth_id,
980
1204
  cis_data=cis_data,
981
1205
  crm_data=crm_data,
982
- control_implementations=implementations,
983
1206
  version=version,
984
1207
  )
1208
+ update_responsiblity_text()
985
1209
 
986
1210
  report(error_set)
987
1211
 
@@ -1032,12 +1256,171 @@ def rev_4_map(control_id: str) -> Optional[str]:
1032
1256
  return f"{base_id}{f'.{letter}' if letter else ''}"
1033
1257
 
1034
1258
 
1259
+ def build_implementations_dict(security_plan_id) -> None:
1260
+ """
1261
+ Save the implementations to a dictionary
1262
+
1263
+ :param int security_plan_id: The security plan id
1264
+ :rtype: None
1265
+ :return: None
1266
+ """
1267
+ logger.info("Saving to an implementation dictionary ..")
1268
+ imps = ControlImplementation.get_list_by_plan(security_plan_id)
1269
+ for imp in imps:
1270
+ EXISTING_IMPLEMENTATIONS[imp.controlID] = imp
1271
+ logger.debug("Built %s implementations", len(imps))
1272
+
1273
+
1274
+ def create_new_security_plan(profile_id: int, system_name: str):
1275
+ """
1276
+ Create a new FedRamp security plan and map controls based on the profile id.
1277
+
1278
+ :param int profile_id: The profile id to map controls from
1279
+ :param str system_name: The system name to create the security plan for
1280
+ :rtype: SecurityPlan
1281
+ :return: The created security plan
1282
+ """
1283
+ compliance_settings = ComplianceSettings.get_by_current_tenant()
1284
+ try:
1285
+ compliance_setting = next(
1286
+ (
1287
+ setting.id
1288
+ for setting in compliance_settings
1289
+ if setting and setting.title == "FedRAMP Compliance Setting"
1290
+ ),
1291
+ 2,
1292
+ )
1293
+ except TypeError:
1294
+ compliance_setting = 2
1295
+ existing_plans = SecurityPlan.get_list()
1296
+ existing_plan = [plan for plan in existing_plans if plan and plan.systemName == system_name]
1297
+ if not existing_plan:
1298
+ profile = Profile.get_object(profile_id)
1299
+ if not profile:
1300
+ error_and_exit("Unable to find the profile with the given ID, please try again")
1301
+ logger.info(f"Loading Profile Mappings from profile #{profile.id} - {profile.name}..")
1302
+ ret = SecurityPlan(
1303
+ **{
1304
+ "status": "Under Development",
1305
+ "systemType": "Major Application",
1306
+ "systemName": system_name,
1307
+ "users": 0,
1308
+ "privilegedUsers": 0,
1309
+ "usersMFA": 0,
1310
+ "privilegedUsersMFA": 0,
1311
+ "internalUsers": 0,
1312
+ "externalUsers": 0,
1313
+ "internalUsersFuture": 0,
1314
+ "externalUsersFuture": 0,
1315
+ "hva": False,
1316
+ "isPublic": True,
1317
+ "bModelSaaS": False,
1318
+ "bModelPaaS": False,
1319
+ "bModelIaaS": False,
1320
+ "bModelOther": False,
1321
+ "otherModelRemarks": "",
1322
+ "bDeployPrivate": False,
1323
+ "bDeployPublic": False,
1324
+ "bDeployGov": False,
1325
+ "bDeployHybrid": False,
1326
+ "bDeployOther": False,
1327
+ "fedrampDateSubmitted": "",
1328
+ "fedrampDateAuthorized": "",
1329
+ "defaultAssessmentDays": 0,
1330
+ "complianceSettingsId": compliance_setting,
1331
+ }
1332
+ ).create()
1333
+ logger.info(f"Created the new Security Plan as ID# {ret.id}")
1334
+ logger.info("Building the implementations from the profile mappings ..")
1335
+ Profile.apply_profile(ret.id, "securityplans", profile_id, True)
1336
+ build_implementations_dict(security_plan_id=ret.id)
1337
+
1338
+ else:
1339
+ ret = next((plan for plan in existing_plan), None)
1340
+ existing_imps = ControlImplementation.get_list_by_plan(ret.id)
1341
+ for imp in existing_imps:
1342
+ EXISTING_IMPLEMENTATIONS[imp.controlID] = imp
1343
+
1344
+ if ret is None:
1345
+ raise ValueError("Unable to create a new security plan.")
1346
+
1347
+ if not EXISTING_IMPLEMENTATIONS:
1348
+ # We must have some implementations, build them if empty.
1349
+ Profile.apply_profile(ret.id, "securityplans", profile_id, True)
1350
+ build_implementations_dict(security_plan_id=ret.id)
1351
+
1352
+ return ret
1353
+
1354
+
1355
+ def parameter_merge(implementation_id: int, security_control_id: int):
1356
+ """
1357
+ Merge parameters for a given implementation ID and security control ID.
1358
+
1359
+ :param int implementation_id: The implementation ID
1360
+ :param int security_control_id: The security control ID
1361
+ :rtype: None
1362
+ """
1363
+ parameters = Parameter.merge_parameters(implementation_id, security_control_id)
1364
+ for param in parameters:
1365
+ param.create()
1366
+
1367
+
1368
+ def objective_merge(implementation_id: int, security_control_id: int):
1369
+ """
1370
+ Merge objectives for a given implementation ID and security control ID.
1371
+
1372
+ :param int implementation_id: The implementation ID
1373
+ :param int security_control_id: The security control ID
1374
+ :rtype: None
1375
+ """
1376
+ imp_objectives = ImplementationObjective.merge_objectives(implementation_id, security_control_id)
1377
+ for obj in imp_objectives:
1378
+ obj.create()
1379
+
1380
+
1381
+ def _spin_off_thread(function: Callable[..., T], *args: Any) -> Thread:
1382
+ """
1383
+ Spin off a thread to run the function with the given arguments.
1384
+
1385
+ :param function: The function to run
1386
+ :param args: The arguments to pass to the function
1387
+ :return: The thread object
1388
+ """
1389
+ thread = Thread(target=function, args=args)
1390
+ thread.start()
1391
+ return thread
1392
+
1393
+
1394
+ def _check_sheet_names_exist(
1395
+ file_path: click.Path, cis_sheet_name: str, crm_sheet_name: str
1396
+ ) -> dict[str, "pd.DataFrame"]:
1397
+ """
1398
+ Check if the sheet names exist in the workbook.
1399
+
1400
+ :param click.Path file_path: The file path to the FedRAMP CIS CRM workbook
1401
+ :param str cis_sheet_name: The name of the CIS sheet to check
1402
+ :param str crm_sheet_name: The name of the CRM sheet to check
1403
+ :raises SystemExit: If the sheet names do not exist
1404
+ :rtype: dict[str, pd.DataFrame]
1405
+ :return: A dictionary of dataframes for each sheet
1406
+ """
1407
+ pd = get_pandas()
1408
+
1409
+ df = pd.read_excel(file_path, sheet_name=None)
1410
+ sheet_names = df.keys()
1411
+ if cis_sheet_name not in sheet_names:
1412
+ error_and_exit(f"The CIS sheet name '{cis_sheet_name}' does not exist in the workbook.")
1413
+ if crm_sheet_name and crm_sheet_name not in sheet_names:
1414
+ error_and_exit(f"The CRM sheet name '{crm_sheet_name}' does not exist in the workbook.")
1415
+ return df
1416
+
1417
+
1035
1418
  def parse_and_import_ciscrm(
1036
1419
  file_path: click.Path,
1037
1420
  version: Literal["rev4", "rev5", "4", "5"],
1038
1421
  cis_sheet_name: str,
1039
- crm_sheet_name: str,
1040
- regscale_ssp_id: int,
1422
+ crm_sheet_name: Optional[str],
1423
+ profile_id: int,
1041
1424
  leveraged_auth_id: int = 0,
1042
1425
  ) -> None:
1043
1426
  """
@@ -1046,17 +1429,17 @@ def parse_and_import_ciscrm(
1046
1429
  :param click.Path file_path: The file path to the FedRAMP CIS CRM .xlsx file
1047
1430
  :param Literal["rev4", "rev5"] version: FedRAMP revision version
1048
1431
  :param str cis_sheet_name: CIS sheet name in the FedRAMP CIS CRM .xlsx to parse
1049
- :param str crm_sheet_name: CRM sheet name in the FedRAMP CIS CRM .xlsx to parse
1050
- :param int regscale_ssp_id: The ID number from RegScale of the System Security Plan
1432
+ :param Optional[str] crm_sheet_name: CRM sheet name in the FedRAMP CIS CRM .xlsx to parse
1433
+ :param int profile_id: The ID number from RegScale of the RegScale Profile to generate the control mapping
1051
1434
  :param int leveraged_auth_id: RegScale Leveraged Authorization ID #, if none provided, one will be created
1052
1435
  :raises ValueError: If the SSP with the given ID is not found in RegScale
1053
1436
  :rtype: None
1054
1437
  """
1438
+ global REGSCALE_SSP_ID # Declare that you're modifying the global variable
1055
1439
  sys_name_key = "System Name"
1056
1440
  api = Api()
1057
- ssp: SecurityPlan = SecurityPlan.get_object(regscale_ssp_id)
1058
- if not ssp:
1059
- raise ValueError(f"SSP with ID {regscale_ssp_id} not found in RegScale.")
1441
+
1442
+ df = _check_sheet_names_exist(file_path, cis_sheet_name, crm_sheet_name)
1060
1443
 
1061
1444
  if "5" in version:
1062
1445
  version = "rev5"
@@ -1065,12 +1448,25 @@ def parse_and_import_ciscrm(
1065
1448
  version = "rev4"
1066
1449
  part_mapper_rev4.load_fedramp_version_4_mapping()
1067
1450
  # parse the instructions worksheet to get the csp name, system name, and other data
1068
- instructions_data = parse_instructions_worksheet(file_path=file_path, version=version) # type: ignore
1451
+ instructions_data = parse_instructions_worksheet(df=df, version=version) # type: ignore
1069
1452
 
1070
1453
  # get the system names from the instructions data by dropping any non-string values
1071
- system_names = [entry[sys_name_key] for entry in instructions_data if isinstance(entry[sys_name_key], str)]
1454
+
1455
+ system_names = [
1456
+ entry[sys_name_key]
1457
+ for entry in instructions_data
1458
+ if isinstance(entry[sys_name_key], str) and cis_sheet_name in entry[sys_name_key].lower()
1459
+ ]
1460
+ if not system_names:
1461
+ system_names = [entry[sys_name_key] for entry in instructions_data if isinstance(entry[sys_name_key], str)]
1072
1462
  name_match: str = system_names[0]
1073
1463
 
1464
+ # create the new security plan
1465
+ ssp: SecurityPlan = create_new_security_plan(profile_id=profile_id, system_name=name_match)
1466
+ REGSCALE_SSP_ID = ssp.id
1467
+
1468
+ if not ssp:
1469
+ raise ValueError("Unable to create a new SSP.")
1074
1470
  # update the instructions data to the matched system names
1075
1471
  instructions_data = [
1076
1472
  (
@@ -1091,9 +1487,7 @@ def parse_and_import_ciscrm(
1091
1487
  cis_data = parse_cis_worksheet(file_path=file_path, cis_sheet_name=cis_sheet_name)
1092
1488
  crm_data = {}
1093
1489
  if crm_sheet_name:
1094
- crm_data = parse_crm_worksheet(
1095
- file_path=file_path, crm_sheet_name=crm_sheet_name, version=version # type: ignore
1096
- )
1490
+ crm_data = parse_crm_worksheet(file_path=file_path, crm_sheet_name=crm_sheet_name, version=version) # type: ignore
1097
1491
  if leveraged_auth_id == 0:
1098
1492
  auths = LeveragedAuthorization.get_all_by_parent(ssp.id)
1099
1493
  if auths:
@@ -1101,7 +1495,7 @@ def parse_and_import_ciscrm(
1101
1495
  else:
1102
1496
  leveraged_auth_id = new_leveraged_auth(
1103
1497
  ssp=ssp,
1104
- user_id=api.config["userId"],
1498
+ user_id=api.app.config["userId"],
1105
1499
  instructions_data=instructions_data,
1106
1500
  version=version, # type: ignore
1107
1501
  )
@@ -1109,7 +1503,6 @@ def parse_and_import_ciscrm(
1109
1503
  parse_and_map_data(
1110
1504
  leveraged_auth_id=leveraged_auth_id,
1111
1505
  api=api,
1112
- ssp_id=regscale_ssp_id,
1113
1506
  cis_data=cis_data,
1114
1507
  crm_data=crm_data,
1115
1508
  version=version, # type: ignore
@@ -1118,7 +1511,7 @@ def parse_and_import_ciscrm(
1118
1511
  # upload workbook to the SSP
1119
1512
  File.upload_file_to_regscale(
1120
1513
  file_name=str(file_path),
1121
- parent_id=regscale_ssp_id,
1514
+ parent_id=ssp.id,
1122
1515
  parent_module="securityplans",
1123
1516
  api=api,
1124
1517
  )