Repository: oscal-compass/compliance-trestle
Pull request #1499 (2024-01-17 19:14:19)

feat: multiple parms per rule
## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Hot fix (emergency fix and release)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Documentation (change which affects the documentation site)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Release (`develop` -> `main`)

## Quality assurance (all should be covered).

- [x] My code follows the code style of this project.
- [ ] Documentation for my change is up to date?
- [x] My PR meets testing requirements.
- [x] All new and existing tests passed.
- [x] All commits are signed-off.

## Summary

Address issue #1495 

## Key links:

- [Sonar coverage](https://sonarcloud.io/dashboard?id=compliance-trestle)

# Before you merge

- Ensure it is a 'squash commit' if not a release.
- Ensure CI is currently passing
- Check sonar. If you are working for a fork a maintainer will reach out, if required.



Patch:
diff --git a/trestle/tasks/csv_to_oscal_cd.py b/trestle/tasks/csv_to_oscal_cd.py
index 1a0a11c7..da863a64 100644
--- a/trestle/tasks/csv_to_oscal_cd.py
+++ b/trestle/tasks/csv_to_oscal_cd.py
@@ -56,10 +56,11 @@ CHECK_ID = 'Check_Id'
 CHECK_DESCRIPTION = 'Check_Description'
 FETCHER_ID = 'Fetcher_Id'
 FETCHER_DESCRIPTION = 'Fetcher_Description'
-PARAMETER_ID = 'Parameter_Id'
-PARAMETER_DESCRIPTION = 'Parameter_Description'
-PARAMETER_VALUE_DEFAULT = 'Parameter_Value_Default'
-PARAMETER_VALUE_ALTERNATIVES = 'Parameter_Value_Alternatives'
+PARAMETER = 'Parameter'
+PARAMETER_ID = f'{PARAMETER}_Id'
+PARAMETER_DESCRIPTION = f'{PARAMETER}_Description'
+PARAMETER_VALUE_DEFAULT = f'{PARAMETER}_Value_Default'
+PARAMETER_VALUE_ALTERNATIVES = f'{PARAMETER}_Value_Alternatives'
 
 validation = 'validation'
 prefix_rule_set = 'rule_set_'
@@ -183,19 +184,17 @@ class CsvToOscalComponentDefinition(TaskBase):
         logger.info(text1 + text2 + text3)
         text1 = '  required columns:      '
         for text2 in CsvColumn.get_required_column_names():
-            if text2 in ['Rule_Description', 'Profile_Source', 'Profile_Description', 'Control_Id_List']:
+            if text2 in [f'{RULE_DESCRIPTION}', f'{PROFILE_SOURCE}', f'{PROFILE_DESCRIPTION}', f'{CONTROL_ID_LIST}']:
                 text2 += ' (see note 1)'
             logger.info(text1 + '$$' + text2)
             text1 = '                         '
         text1 = '  optional columns:      '
         for text2 in CsvColumn.get_optional_column_names():
-            if text2 in ['Parameter_Id',
-                         'Parameter_Description',
-                         'Parameter_Value_Alternatives',
-                         'Parameter_Value_Default']:
-                text2 += ' (see note 1)'
-            if text2 in ['Check_Id', 'Check_Description']:
-                text2 += ' (see note 2)'
+            text2 += ' (see note 2)'
+            logger.info(text1 + '$' + text2)
+            text1 = '                         '
+        for text2 in CsvColumn.get_parameter_column_names():
+            text2 += ' (see notes 1, 4)'
             logger.info(text1 + '$' + text2)
             text1 = '                         '
         text1 = '  comment columns:       '
@@ -229,6 +228,10 @@ class CsvToOscalComponentDefinition(TaskBase):
         text1 = '       '
         text2 = '[3] column name starting with # causes column to be ignored'
         logger.info(text1 + text2)
+        text1 = '       '
+        text2 = '[4] additional parameters are specified by adding a common suffix per set'
+        text3 = f', for example: {PARAMETER_ID}_1, {PARAMETER_DESCRIPTION}_1, ...{PARAMETER_ID}_2...'
+        logger.info(text1 + text2 + text3)
 
     def configure(self) -> bool:
         """Configure."""
@@ -454,7 +457,7 @@ class CsvToOscalComponentDefinition(TaskBase):
         for prop in component.props:
             if prop.remarks != rule_set:
                 props.append(prop)
-            elif prop.name == PARAMETER_ID:
+            elif prop.name in self._csv_mgr.get_parameter_id_column_names():
                 self._delete_rule_set_parameter(component, prop.value)
             elif prop.name == RULE_ID:
                 self._delete_rule_implemented_requirement(component, prop.value)
@@ -559,11 +562,11 @@ class CsvToOscalComponentDefinition(TaskBase):
                 source = self._csv_mgr.get_value(rule_key, PROFILE_SOURCE)
                 description = self._csv_mgr.get_value(rule_key, PROFILE_DESCRIPTION)
                 control_implementation = self._get_control_implementation(component, source, description)
-                # set-parameter
-                set_parameter = self._create_set_parameter(rule_key)
-                if set_parameter:
+                # set-parameters
+                set_parameters = self._create_set_parameters(rule_key)
+                if set_parameters:
                     control_implementation.set_parameters = as_list(control_implementation.set_parameters)
-                    _OscalHelper.add_set_parameter(control_implementation.set_parameters, set_parameter)
+                    _OscalHelper.add_set_parameters(control_implementation.set_parameters, set_parameters)
                 # control-mappings
                 control_mappings = self._csv_mgr.get_value(rule_key, CONTROL_ID_LIST).split()
                 self._add_rule_prop(control_implementation, control_mappings, rule_key)
@@ -614,7 +617,7 @@ class CsvToOscalComponentDefinition(TaskBase):
             rule_set_mgr.add_prop(prop_name, prop_value, namespace, self.get_class(prop_name))
         if not self._is_validation(rule_key):
             # parameter columns
-            column_names = CsvColumn.get_parameter_column_names()
+            column_names = self._csv_mgr.get_parameter_column_names()
             for column_name in column_names:
                 prop_name = self._get_prop_name(column_name)
                 prop_value = self._csv_mgr.get_value(rule_key, column_name).strip()
@@ -655,23 +658,26 @@ class CsvToOscalComponentDefinition(TaskBase):
             rval.append(value)
         return rval
 
-    def _create_set_parameter(self, rule_key: tuple) -> SetParameter:
-        """Create create set parameters."""
-        set_parameter = None
-        name = self._csv_mgr.get_value(rule_key, PARAMETER_ID)
-        value = self._csv_mgr.get_value(rule_key, PARAMETER_VALUE_DEFAULT)
-        if name and value:
-            values = self._str_to_list(value)
-            set_parameter = SetParameter(
-                param_id=name,
-                values=values,
-            )
-        elif name:
-            row_number = self._csv_mgr.get_row_number(rule_key)
-            column_name = PARAMETER_VALUE_DEFAULT
-            text = f'row "{row_number}" missing value for "{column_name}"'
-            logger.debug(text)
-        return set_parameter
+    def _create_set_parameters(self, rule_key: tuple) -> List[SetParameter]:
+        """Create set parameters."""
+        set_parameters = []
+        for parameter_id_column_name in self._csv_mgr.get_parameter_id_column_names():
+            suffix = parameter_id_column_name.replace(PARAMETER_ID, '')
+            parameter_value_default_column_name = f'{PARAMETER_VALUE_DEFAULT}{suffix}'
+            name = self._csv_mgr.get_value(rule_key, parameter_id_column_name)
+            value = self._csv_mgr.get_value(rule_key, parameter_value_default_column_name)
+            if name and value:
+                values = self._str_to_list(value)
+                set_parameter = SetParameter(
+                    param_id=name,
+                    values=values,
+                )
+                set_parameters.append(set_parameter)
+            elif name:
+                row_number = self._csv_mgr.get_row_number(rule_key)
+                text = f'row "{row_number}" missing value for "{parameter_value_default_column_name}"'
+                logger.debug(text)
+        return set_parameters
 
     def _get_implemented_requirement(
         self, control_implementation: ControlImplementation, control_id: str
@@ -730,7 +736,7 @@ class CsvToOscalComponentDefinition(TaskBase):
             class_ = self.get_class(column_name)
             self._cd_mgr.update_rule_definition(component, rule_set, column_name, column_value, rule_ns, class_)
         # parameter columns
-        column_names = CsvColumn.get_parameter_column_names()
+        column_names = self._csv_mgr.get_parameter_column_names()
         for column_name in column_names:
             column_value = self._csv_mgr.get_value(rule_key, column_name).strip()
             class_ = self.get_class(column_name)
@@ -778,7 +784,7 @@ class CsvToOscalComponentDefinition(TaskBase):
             control_implementation.set_parameters = as_list(control_implementation.set_parameters)
             # add
             rule_key = _CsvMgr.get_rule_key(component_title, component_type, rule_id)
-            values = [self._csv_mgr.get_value(rule_key, PARAMETER_VALUE_DEFAULT)]
+            values = [self._csv_mgr.get_default_value_by_id(rule_key, param_id)]
             set_parameter = SetParameter(
                 param_id=param_id,
                 values=values,
@@ -803,7 +809,7 @@ class CsvToOscalComponentDefinition(TaskBase):
                     if set_parameter.param_id != param_id:
                         continue
                     rule_key = _CsvMgr.get_rule_key(component_title, component_type, rule_id)
-                    values = [self._csv_mgr.get_value(rule_key, PARAMETER_VALUE_DEFAULT)]
+                    values = [self._csv_mgr.get_default_value_by_id(rule_key, param_id)]
                     replacement = SetParameter(
                         param_id=param_id,
                         values=values,
@@ -888,6 +894,12 @@ class CsvToOscalComponentDefinition(TaskBase):
 class _OscalHelper():
     """Oscal Helper."""
 
+    @staticmethod
+    def add_set_parameters(set_parameter_list: List[SetParameter], set_parameter_list_add: List[SetParameter]) -> None:
+        """Add set parameters."""
+        for set_parameter in set_parameter_list_add:
+            _OscalHelper.add_set_parameter(set_parameter_list, set_parameter)
+
     @staticmethod
     def add_set_parameter(set_parameter_list: List[SetParameter], set_parameter: SetParameter) -> None:
         """Add set parameter."""
@@ -983,16 +995,25 @@ class _RuleSetMgr():
     def get_props(self) -> List[Property]:
         """Get props."""
         rval = []
+        # required
         c1 = CsvColumn.get_required_column_names()
         for key in c1:
             if key in self._props.keys():
                 rval.append(self._props[key])
+        # parameter
+        c3 = []
+        for key in self._props.keys():
+            if key.startswith(f'{PARAMETER}'):
+                rval.append(self._props[key])
+                c3.append(key)
+        # optional
         c2 = CsvColumn.get_optional_column_names()
         for key in c2:
             if key in self._props.keys():
                 rval.append(self._props[key])
+        # user
         for key in self._props.keys():
-            if key in c1 or key in c2:
+            if key in c1 or key in c2 or key in c3:
                 continue
             rval.append(self._props[key])
         return rval
@@ -1333,15 +1354,6 @@ class CsvColumn():
         f'{NAMESPACE}',
     ]
 
-    _columns_optional = [
-        f'{PARAMETER_ID}',
-        f'{PARAMETER_DESCRIPTION}',
-        f'{PARAMETER_VALUE_ALTERNATIVES}',
-        f'{PARAMETER_VALUE_DEFAULT}',
-        f'{CHECK_ID}',
-        f'{CHECK_DESCRIPTION}',
-    ]
-
     _columns_required_validation = [
         f'{COMPONENT_TITLE}',
         f'{COMPONENT_DESCRIPTION}',
@@ -1352,15 +1364,46 @@ class CsvColumn():
         f'{CHECK_DESCRIPTION}',
     ]
 
+    _columns_optional = [
+        f'{CHECK_ID}',
+        f'{CHECK_DESCRIPTION}',
+    ]
+
+    _columns_parameter = [
+        f'{PARAMETER_ID}',
+        f'{PARAMETER_DESCRIPTION}',
+        f'{PARAMETER_VALUE_ALTERNATIVES}',
+        f'{PARAMETER_VALUE_DEFAULT}',
+    ]
+
+    _columns_ordered = _columns_required + _columns_parameter + _columns_optional
+
     @staticmethod
     def get_order(column_name: str) -> int:
         """Get order for column_name."""
         rval = sys.maxsize
-        columns_ordered = CsvColumn._columns_required + CsvColumn._columns_optional
-        if column_name in columns_ordered:
-            rval = columns_ordered.index(column_name)
+        if column_name in CsvColumn._columns_ordered:
+            rval = CsvColumn._columns_ordered.index(column_name)
         return rval
 
+    @staticmethod
+    def is_column_name_required(name: str) -> bool:
+        """Is column name required."""
+        return name in (CsvColumn._columns_required + CsvColumn._columns_required_validation)
+
+    @staticmethod
+    def is_column_name_optional(name: str) -> bool:
+        """Is column name optional."""
+        return name in (CsvColumn._columns_optional)
+
+    @staticmethod
+    def is_column_name_parameter(name: str) -> bool:
+        """Is column name parameter."""
+        for cname in CsvColumn._columns_parameter:
+            if name.startswith(cname):
+                return True
+        return False
+
     @staticmethod
     def get_required_column_names() -> List[str]:
         """Get required column names."""
@@ -1376,11 +1419,10 @@ class CsvColumn():
         return rval
 
     @staticmethod
-    def get_reserved_column_names() -> List[str]:
-        """Get reserved column names."""
+    def get_parameter_column_names() -> List[str]:
+        """Get parameter column names."""
         rval = []
-        rval += CsvColumn._columns_required
-        rval += CsvColumn._columns_optional
+        rval += CsvColumn._columns_parameters
         return rval
 
     @staticmethod
@@ -1417,12 +1459,7 @@ class CsvColumn():
     ]
 
     # optional columns which do not become properties, initially
-    _columns_optional_filtered = [
-        f'{PARAMETER_ID}',
-        f'{PARAMETER_DESCRIPTION}',
-        f'{PARAMETER_VALUE_ALTERNATIVES}',
-        f'{PARAMETER_VALUE_DEFAULT}',
-    ]
+    _columns_optional_filtered = []
 
     _columns_filtered = _columns_required_filtered + _columns_optional_filtered
 
@@ -1462,13 +1499,6 @@ class CsvColumn():
         f'{PARAMETER_VALUE_ALTERNATIVES}',
     ]
 
-    @staticmethod
-    def get_parameter_column_names() -> List[str]:
-        """Get parameter column names."""
-        rval = []
-        rval += CsvColumn._columns_parameters
-        return rval
-
     # optional columns which require Param_Id be present in the row
     _columns_parameters_dependent = [
         f'{PARAMETER_DESCRIPTION}',
@@ -1477,6 +1507,9 @@ class CsvColumn():
     ]
 
 
+Row = Iterator[List[str]]
+
+
 class _CsvMgr():
     """Csv Manager."""
 
@@ -1511,32 +1544,64 @@ class _CsvMgr():
             if source and source not in self._csv_profile_list:
                 self._csv_profile_list.append(source)
             description = self.get_row_value(row, PROFILE_DESCRIPTION)
-            param_id = self.get_row_value(row, PARAMETER_ID)
-            if param_id:
-                key = (component_title, component_type, rule_id, source, description, param_id)
-                self._csv_set_params_map[key] = [row_num, row]
-                logger.debug(f'csv-set-parameters: {key} {self._csv_set_params_map[key][0]}')
+            for param_id_key in self.get_parameter_id_column_names():
+                param_id = self.get_row_value(row, param_id_key)
+                if param_id:
+                    key = (component_title, component_type, rule_id, source, description, param_id)
+                    self._csv_set_params_map[key] = [row_num, row]
+                    logger.debug(f'csv-set-parameters: {key} {self._csv_set_params_map[key][0]}')
             # control mappings
-            control_mappings = self.get_row_value(row, CONTROL_ID_LIST)
-            if control_mappings:
-                controls = control_mappings.split()
-                for control in controls:
-                    key = (component_description, component_type, rule_id, source, description, control)
-                    self._csv_controls_map[key] = [row_num, row]
+            self._control_mappings(row_num, row, component_description, component_type, rule_id, source, description)
         logger.debug(f'csv rules: {len(self._csv_rules_map)}')
         logger.debug(f'csv params: {len(self._csv_set_params_map)}')
         logger.debug(f'csv controls: {len(self._csv_controls_map)}')
 
+    def _control_mappings(
+        self,
+        row_num: int,
+        row: Row,
+        component_description: str,
+        component_type: str,
+        rule_id: str,
+        source: str,
+        description: str
+    ) -> None:
+        """Control_mappings."""
+        control_mappings = self.get_row_value(row, CONTROL_ID_LIST)
+        if control_mappings:
+            controls = control_mappings.split()
+            for control in controls:
+                key = (component_description, component_type, rule_id, source, description, control)
+                self._csv_controls_map[key] = [row_num, row]
+
     @staticmethod
     def get_rule_key(component_title: str, component_type: str, rule_id: str) -> tuple:
         """Get rule_key."""
         return (component_title, component_type, rule_id)
 
+    def get_parameter_id_column_names(self) -> List[str]:
+        """Get parameter_id column_names."""
+        col_names = []
+        for col_name in self._csv[0]:
+            if col_name.startswith(PARAMETER_ID):
+                col_names.append(col_name)
+        return col_names
+
+    def get_parameter_column_names(self) -> List[str]:
+        """Get parameter column_names."""
+        col_names = []
+        for col_name in self._csv[0]:
+            if col_name.startswith(PARAMETER_VALUE_DEFAULT):
+                continue
+            if col_name.startswith(PARAMETER):
+                col_names.append(col_name)
+        return col_names
+
     def get_profile_list(self):
         """Get profile list."""
         return [] + self._csv_profile_list
 
-    def row_generator(self) -> Generator[Union[int, Iterator[List[str]]], None, None]:
+    def row_generator(self) -> Generator[Union[int, Row], None, None]:
         """Generate rows."""
         index = 0
         for row in self._csv:
@@ -1652,11 +1717,25 @@ class _CsvMgr():
         row = self.get_row(rule_key)
         return self.get_row_value(row, name)
 
+    def get_default_value_by_id(self, rule_key: tuple, name: str) -> str:
+        """Get default value for specified parameter id."""
+        rval = None
+        for parameter_id_column_name in self.get_parameter_id_column_names():
+            parameter_id_name = self.get_value(rule_key, parameter_id_column_name)
+            if name == parameter_id_name:
+                suffix = parameter_id_column_name.replace(PARAMETER_ID, '')
+                parameter_value_default_column_name = f'{PARAMETER_VALUE_DEFAULT}{suffix}'
+                rval = self.get_value(rule_key, parameter_value_default_column_name)
+                break
+        return rval
+
     def get_user_column_names(self) -> List[str]:
         """Get user column names."""
         user_column_names = []
-        reserved_column_names = CsvColumn.get_reserved_column_names()
         for column_name in self._csv[0]:
-            if column_name not in reserved_column_names:
+            t1 = CsvColumn.is_column_name_required(column_name)
+            t2 = CsvColumn.is_column_name_optional(column_name)
+            t3 = CsvColumn.is_column_name_parameter(column_name)
+            if not (t1 or t2 or t3):
                 user_column_names.append(column_name)
         return user_column_names


Test patch:
diff --git a/tests/data/csv/bp.sample.v4.csv b/tests/data/csv/bp.sample.v4.csv
new file mode 100644
index 00000000..ec11adf5
--- /dev/null
+++ b/tests/data/csv/bp.sample.v4.csv
@@ -0,0 +1,12 @@
+Reference_Id,Rule_Id,Rule_Description,Check_Id,Check_Description,Fetcher,Fetcher_Description,Profile_Source,Profile_Description,Component_Type,Control_Id_List,Component_Title,Component_Description,Parameter_Id,Parameter_Description,Parameter_Value_Default,Parameter_Value_Alternatives,Parameter_Id2,Parameter_Description2,Parameter_Value_Default2,Parameter_Value_Alternatives2,Namespace
+column description,column description,column description,,,,,,,,,,,,,,,,,,,
+3000020,account_owner_authorized_ip_range_configured,Ensure authorized IP ranges are configured by the account owner,account_owner_authorized_ip_range_configured,Check whether authorized IP ranges are configured by the account owner,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,sc-7_smt.a sc-7_smt.b sc-7.3 sc-7.4_smt.a sc-7.5 ia-3,IAM,IAM,,,,,,,,,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000021,iam_admin_role_users_per_account_maxcount,Ensure there are no more than # IAM administrators configured per account,iam_admin_role_users_per_account_maxcount,Check whether there are no more than # IAM administrators configured per account,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-6 ac-5_smt.c,IAM,IAM,allowed_admins_per_account,Maximum allowed administrators per,10,10,allowed_admins_per_account2,Maximum allowed administrators per2,20,20,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000022,iam_cos_public_access_disabled,Ensure Cloud Object Storage public access is disabled in IAM settings (not applicable to ACLs managed using S3 APIs),iam_cos_public_access_disabled,Check whether Cloud Object Storage public access is disabled in IAM settings (not applicable to ACLs managed using S3 APIs),,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-3 ac-4 ac-6 sc-7_smt.a sc-7_smt.b sc-7.4_smt.a ac-14_smt.a cm-7_smt.a cm-7_smt.b,IAM,IAM,,,,,,,,,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000023,iam_account_owner_no_api_key,Ensure the account owner does not have an IBM Cloud API key created in IAM,iam_account_owner_no_api_key,Check whether the account owner does not have an IBM Cloud API key created in IAM,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-2_smt.d ac-3 ac-5_smt.c ac-6,IAM,IAM,,,,,,,,,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000024,iam_api_keys_rotation_configured,Ensure IBM Cloud API keys that are managed in IAM are rotated at least every # days,iam_api_keys_rotation_configured,Check whether IBM Cloud API keys that are managed in IAM are rotated at least every # days,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ia-5_smt.g,IAM,IAM,api_keys_rotated_days,API Keys Rotated,"x, y, z",,api_keys_rotated_days2,API Keys Rotated2,"r, s, t",,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000027,iam_account_owner_api_key_restrictions_configured,Ensure permissions for API key creation are limited and configured in IAM settings for the account owner,iam_account_owner_api_key_restrictions_configured,Check whether permissions for API key creation are limited and configured in IAM settings for the account owner,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-2_smt.d ac-3 ac-5_smt.c ac-6,IAM,IAM,,,,,,,,,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000029,iam_admin_role__user_maxcount,Ensure IAM-enabled services have no more than # users with the IAM administrator role,iam_admin_role__user_maxcount,Check whether IAM-enabled services have no more than # users with the IAM administrator role,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-6 ac-5_smt.c ia-7,IAM,IAM,no_of_admins_for_iam,Maximum no of IAM user,"a, b, c",,no_of_admins_for_iam2,Maximum no of IAM user2,"d, e, f",,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000030,iam_serviceID_policies_attached_to_access_groups_or_roles,Ensure IAM policies for service IDs are attached only to groups or roles,iam_serviceID_policies_attached_to_access_groups_or_roles,Check whether IAM policies for service IDs are attached only to groups or roles,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-3 ac-6 ac-2_smt.d ac-5_smt.c ia-7,IAM,IAM,,,,,,,,,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000031,iam_logDNA_enabled,Ensure Identity and Access Management (IAM) is enabled with audit logging,iam_logDNA_enabled,Check whether Identity and Access Management (IAM) is enabled with audit logging,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,au-2_smt.a au-2_smt.d si-4_smt.a si-4_smt.b si-4_smt.c au-12_smt.a au-12_smt.b au-12_smt.c au-3 au-8_smt.a au-8_smt.b au-8.1_smt.a au-8.1_smt.b ca-7_smt.d,IAM,IAM,,,,,,,,,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
+3000032,iam_admin_role_serviceid_maxcount,Ensure IAM-enabled services have no more than # service IDs with the IAM administrator role,iam_admin_role_serviceid_maxcount,Check whether IAM-enabled services have no more than # service IDs with the IAM administrator role,,,https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile.json,NIST Special Publication 800-53 Revision 5 HIGH IMPACT BASELINE,Service,ac-6 ac-5_smt.c ia-7,IAM,IAM,no_of_service_id_admins_for_iam,Maximum no of IAM Service ID,"3, 4, 5",,no_of_service_id_admins_for_iam2,Maximum no of IAM Service ID2,"10, 11, 12",,http://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd
diff --git a/tests/trestle/tasks/csv_to_oscal_cd_test.py b/tests/trestle/tasks/csv_to_oscal_cd_test.py
index cabfc541..37286f5b 100644
--- a/tests/trestle/tasks/csv_to_oscal_cd_test.py
+++ b/tests/trestle/tasks/csv_to_oscal_cd_test.py
@@ -470,6 +470,158 @@ def test_execute_bp3_sample(tmp_path: pathlib.Path) -> None:
     assert ci.set_parameters[3].values[2] == '5'
 
 
+def test_execute_bp4_sample(tmp_path: pathlib.Path) -> None:
+    """Test execute bp4 sample."""
+    _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config')
+    section['csv-file'] = 'tests/data/csv/bp.sample.v4.csv'
+    # perform transformation from csv to OSCAL json
+    tgt = csv_to_oscal_cd.CsvToOscalComponentDefinition(section)
+    retval = tgt.execute()
+    assert retval == TaskOutcome.SUCCESS
+    # read component-definition
+    fp = pathlib.Path(tmp_path) / 'component-definition.json'
+    cd = ComponentDefinition.oscal_read(fp)
+    # spot check
+    component = cd.components[0]
+    assert len(component.props) == 68
+    assert len(component.control_implementations) == 1
+    # props
+    index = 0
+    assert component.props[index].name == 'Rule_Id'
+    assert component.props[index].value == 'account_owner_authorized_ip_range_configured'
+    assert component.props[index].remarks == 'rule_set_00'
+    index = 7
+    assert component.props[index].name == 'Parameter_Id'
+    assert component.props[index].value == 'allowed_admins_per_account'
+    assert component.props[index].remarks == 'rule_set_01'
+    index = 8
+    assert component.props[index].name == 'Parameter_Description'
+    assert component.props[index].value == 'Maximum allowed administrators per'
+    assert component.props[index].remarks == 'rule_set_01'
+    index = 9
+    assert component.props[index].name == 'Parameter_Value_Alternatives'
+    assert component.props[index].value == '10'
+    assert component.props[index].remarks == 'rule_set_01'
+    index = 10
+    assert component.props[index].name == 'Parameter_Id2'
+    assert component.props[index].value == 'allowed_admins_per_account2'
+    assert component.props[index].remarks == 'rule_set_01'
+    index = 11
+    assert component.props[index].name == 'Parameter_Description2'
+    assert component.props[index].value == 'Maximum allowed administrators per2'
+    assert component.props[index].remarks == 'rule_set_01'
+    index = 12
+    assert component.props[index].name == 'Parameter_Value_Alternatives2'
+    assert component.props[index].value == '20'
+    assert component.props[index].remarks == 'rule_set_01'
+    index = 13
+    assert component.props[index].name == 'Check_Id'
+    assert component.props[index].value == 'iam_admin_role_users_per_account_maxcount'
+    assert component.props[index].remarks == 'rule_set_01'
+    # control implementations
+    ci = component.control_implementations[0]
+    assert len(ci.set_parameters) == 8
+    index = 0
+    assert ci.set_parameters[index].param_id == 'allowed_admins_per_account'
+    assert len(ci.set_parameters[index].values) == 1
+    assert ci.set_parameters[index].values[0] == '10'
+    index = 1
+    assert ci.set_parameters[index].param_id == 'allowed_admins_per_account2'
+    assert len(ci.set_parameters[index].values) == 1
+    assert ci.set_parameters[index].values[0] == '20'
+    index = 2
+    assert ci.set_parameters[index].param_id == 'api_keys_rotated_days'
+    assert len(ci.set_parameters[index].values) == 3
+    assert ci.set_parameters[index].values[0] == 'x'
+    assert ci.set_parameters[index].values[1] == 'y'
+    assert ci.set_parameters[index].values[2] == 'z'
+    index = 3
+    assert ci.set_parameters[index].param_id == 'api_keys_rotated_days2'
+    assert len(ci.set_parameters[index].values) == 3
+    assert ci.set_parameters[index].values[0] == 'r'
+    assert ci.set_parameters[index].values[1] == 's'
+    assert ci.set_parameters[index].values[2] == 't'
+
+
+def test_execute_bp4_modify_additional_param_set(tmp_path: pathlib.Path) -> None:
+    """Test execute bp4 modify additional param set."""
+    _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config')
+    section['csv-file'] = 'tests/data/csv/bp.sample.v4.csv'
+    # modify additional param set
+    rows = _get_rows('tests/data/csv/bp.sample.v4.csv')
+    row = rows[3]
+    assert row[17] == 'allowed_admins_per_account2'
+    assert row[19] == '20'
+    assert row[20] == '20'
+    row[19] = '50'
+    row[20] = '45 50 55'
+    # perform transformation from csv to OSCAL json
+    with mock.patch('trestle.tasks.csv_to_oscal_cd.csv.reader') as mock_csv_reader:
+        mock_csv_reader.return_value = rows
+        tgt = csv_to_oscal_cd.CsvToOscalComponentDefinition(section)
+        retval = tgt.execute()
+        assert retval == TaskOutcome.SUCCESS
+    # read component-definition
+    fp = pathlib.Path(tmp_path) / 'component-definition.json'
+    cd = ComponentDefinition.oscal_read(fp)
+    # spot check
+    component = cd.components[0]
+    assert len(component.props) == 68
+    assert len(component.control_implementations) == 1
+    # props
+    index = 0
+    assert component.props[index].name == 'Rule_Id'
+    assert component.props[index].value == 'account_owner_authorized_ip_range_configured'
+    assert component.props[index].remarks == 'rule_set_00'
+    index = 12
+    assert component.props[index].name == 'Parameter_Value_Alternatives2'
+    assert component.props[index].value == '45 50 55'
+    assert component.props[index].remarks == 'rule_set_01'
+    # control implementations
+    ci = component.control_implementations[0]
+    assert len(ci.set_parameters) == 8
+    index = 0
+    assert ci.set_parameters[index].param_id == 'allowed_admins_per_account'
+    assert len(ci.set_parameters[index].values) == 1
+    assert ci.set_parameters[index].values[0] == '10'
+    index = 1
+    assert ci.set_parameters[index].param_id == 'allowed_admins_per_account2'
+    assert len(ci.set_parameters[index].values) == 1
+    assert ci.set_parameters[index].values[0] == '50'
+
+
+def test_execute_bp4_delete_additional_param_set(tmp_path: pathlib.Path) -> None:
+    """Test execute bp4 delete additional param set."""
+    _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config')
+    section['csv-file'] = 'tests/data/csv/bp.sample.v4.csv'
+    # delete additional param set
+    rows = _get_rows('tests/data/csv/bp.sample.v4.csv')
+    row = rows[3]
+    assert row[17] == 'allowed_admins_per_account2'
+    assert row[19] == '20'
+    assert row[20] == '20'
+    row[17] = ''
+    row[18] = ''
+    row[19] = ''
+    row[20] = ''
+    # perform transformation from csv to OSCAL json
+    with mock.patch('trestle.tasks.csv_to_oscal_cd.csv.reader') as mock_csv_reader:
+        mock_csv_reader.return_value = rows
+        tgt = csv_to_oscal_cd.CsvToOscalComponentDefinition(section)
+        retval = tgt.execute()
+        assert retval == TaskOutcome.SUCCESS
+    # read component-definition
+    fp = pathlib.Path(tmp_path) / 'component-definition.json'
+    cd = ComponentDefinition.oscal_read(fp)
+    # spot check
+    component = cd.components[0]
+    assert len(component.props) == 65
+    assert len(component.control_implementations) == 1
+    # control implementations
+    ci = component.control_implementations[0]
+    assert len(ci.set_parameters) == 7
+
+
 def test_execute_bp_cd(tmp_path: pathlib.Path) -> None:
     """Test execute bp cd."""
     _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config')