def test_bad_unicode_in_file(tmp_path: pathlib.Path) -> None: """Test error on read of bad unicode in control markdown.""" bad_file = tmp_path / 'bad_unicode.md' with open(bad_file, 'wb') as f: f.write(b'\x81') with pytest.raises(TrestleError): ControlIOReader._load_control_lines_and_header(bad_file)
def test_get_control_param_dict(tmp_trestle_dir: pathlib.Path) -> None: """Test getting the param dict of a control.""" test_utils.setup_for_multi_profile(tmp_trestle_dir, False, True) prof_a_path = ModelUtils.path_for_top_level_model(tmp_trestle_dir, 'test_profile_a', prof.Profile, FileContentType.JSON) catalog = ProfileResolver.get_resolved_profile_catalog( tmp_trestle_dir, prof_a_path) catalog_interface = CatalogInterface(catalog) control = catalog_interface.get_control('ac-1') param_dict = ControlIOReader.get_control_param_dict(control, False) # confirm profile value is used assert ControlIOReader.param_values_as_str( param_dict['ac-1_prm_1']) == 'all alert personnel' # confirm original param label is used since no value was assigned assert ControlIOReader.param_to_str(param_dict['ac-1_prm_7'], ParameterRep.VALUE_OR_LABEL_OR_CHOICES ) == 'organization-defined events' param = control.params[0] param.values = None param.select = common.ParameterSelection( how_many=common.HowMany.one_or_more, choice=['choice 1', 'choice 2']) param_dict = ControlIOReader.get_control_param_dict(control, False) assert ControlIOReader.param_to_str( param_dict['ac-1_prm_1'], ParameterRep.VALUE_OR_LABEL_OR_CHOICES) == 'choice 1, choice 2'
def test_control_with_components() -> None: """Test loading and parsing of implementated reqs with components.""" control_path = pathlib.Path( 'tests/data/author/controls/control_with_components.md').resolve() comp_prose_dict, _ = ControlIOReader.read_all_implementation_prose_and_header( control_path) assert len(comp_prose_dict.keys()) == 3 assert len(comp_prose_dict['This System'].keys()) == 3 assert len(comp_prose_dict['Trestle Component'].keys()) == 1 assert len(comp_prose_dict['Fancy Thing'].keys()) == 2 assert comp_prose_dict['Fancy Thing']['a.'] == [ 'Text for fancy thing component' ] # need to build the needed components so they can be referenced comp_dict = {} for comp_name in comp_prose_dict.keys(): comp = gens.generate_sample_model(ossp.SystemComponent) comp.title = comp_name comp_dict[comp_name] = comp # confirm that the header content was inserted into the props of the imp_req imp_req = ControlIOReader.read_implemented_requirement( control_path, comp_dict) assert len(imp_req.props) == 12 assert len(imp_req.statements) == 3 assert len(imp_req.statements[0].by_components) == 3
def test_write_control_header_params(overwrite_header_values, tmp_path: pathlib.Path) -> None: """Test write/read of control header params.""" # orig file just has one param ac-1_prm_3 src_control_path = pathlib.Path( 'tests/data/author/controls/control_with_components_and_params.md') # header has two params - 3 and 4 header = { const.SET_PARAMS_TAG: { 'ac-1_prm_3': { 'values': 'new prm_3 val from input header' }, 'ac-1_prm_4': { 'values': 'new prm_4 val from input header' } }, 'foo': 'new bar', 'new-reviewer': 'James', 'special': 'new value to ignore', 'none-thing': 'none value to ignore' } control_path = tmp_path / 'ac-1.md' shutil.copyfile(src_control_path, control_path) markdown_processor = MarkdownProcessor() # header_1 should have one param: 3 header_1, _ = markdown_processor.read_markdown_wo_processing(control_path) assert len(header_1.keys()) == 8 orig_control_read, group_title = ControlIOReader.read_control( control_path, True) assert group_title == 'Access Control' control_writer = ControlIOWriter() # write the control back out with the test header control_writer.write_control(tmp_path, orig_control_read, group_title, header, None, False, False, None, overwrite_header_values, None, None) # header_2 should have 2 params: 3 and 4 header_2, _ = markdown_processor.read_markdown_wo_processing(control_path) assert len(header_2.keys()) == 9 assert header_2['new-reviewer'] == 'James' assert len(header_2[const.SET_PARAMS_TAG]) == 2 assert 'new' in header_2[const.SET_PARAMS_TAG]['ac-1_prm_4']['values'] if not overwrite_header_values: assert 'orig' in header_2[const.SET_PARAMS_TAG]['ac-1_prm_3']['values'] assert header_2['foo'] == 'bar' assert header_2['special'] == '' assert header_2['none-thing'] is None else: assert 'new' in header_2[const.SET_PARAMS_TAG]['ac-1_prm_3']['values'] assert header_2['foo'] == 'new bar' assert header_2['special'] == 'new value to ignore' assert header_2['none-thing'] == 'none value to ignore' assert 'orig' in orig_control_read.params[0].values[0].__root__ new_control_read, _ = ControlIOReader.read_control(control_path, True) # insert the new param in the orig control so we can compare the two controls orig_control_read.params.append(new_control_read.params[1]) if overwrite_header_values: orig_control_read.params[0] = new_control_read.params[0] assert test_utils.controls_equivalent(orig_control_read, new_control_read)
def test_read_control_no_label(testdata_dir: pathlib.Path) -> None: """Test reading a control that doesn't have a part label in statement.""" md_file = testdata_dir / 'author/controls/control_no_labels.md' control, group_title = ControlIOReader.read_control(md_file, True) assert group_title == 'My Group Title' assert control.parts[0].parts[2].props[0].value == 'c' assert control.parts[0].parts[2].parts[0].props[0].value == '1' md_file = testdata_dir / 'author/controls/control_some_labels.md' control, group_title = ControlIOReader.read_control(md_file, True) assert group_title == 'My Group Title' assert control.parts[0].parts[2].props[0].value == 'aa' assert control.parts[0].parts[2].parts[1].props[0].value == 'abc13' assert control.parts[0].parts[3].props[0].value == 'ab'
def _change_prose_with_param_values(self): """Go through all controls and change prose based on param values.""" param_dict: Dict[str, common.Paramter] = {} # build the full mapping of params to values for control in self._catalog_interface.get_all_controls_from_dict(): param_dict.update( ControlIOReader.get_control_param_dict(control, False)) # insert param values into prose of all controls for control in self._catalog_interface.get_all_controls_from_dict(): self._replace_control_prose(control, param_dict, self._params_format, self._param_rep)
def confirm_control_contains(trestle_dir: pathlib.Path, control_id: str, part_label: str, seek_str: str) -> bool: """Confirm the text is present in the control markdown in the correct part.""" control_dir = trestle_dir / ssp_name / control_id.split('-')[0] md_file = control_dir / f'{control_id}.md' comp_dict, _ = ControlIOReader.read_all_implementation_prose_and_header( md_file) for label_dict in comp_dict.values(): if part_label in label_dict: prose = '\n'.join(label_dict[part_label]) if seek_str in prose: return True return False
def test_get_profile_param_dict(tmp_trestle_dir: pathlib.Path) -> None: """Test get profile param dict for control.""" test_utils.setup_for_multi_profile(tmp_trestle_dir, False, True) profile, profile_path = ModelUtils.load_top_level_model( tmp_trestle_dir, 'test_profile_a', prof.Profile, FileContentType.JSON) profile_resolver = ProfileResolver() catalog = profile_resolver.get_resolved_profile_catalog( tmp_trestle_dir, profile_path) catalog_interface = CatalogInterface(catalog) control = catalog_interface.get_control('ac-1') full_param_dict = CatalogInterface._get_full_profile_param_dict(profile) control_param_dict = CatalogInterface._get_profile_param_dict( control, full_param_dict, False) assert ControlIOReader.param_to_str( control_param_dict['ac-1_prm_1'], ParameterRep.VALUE_OR_LABEL_OR_CHOICES) == 'all alert personnel' assert ControlIOReader.param_to_str( control_param_dict['ac-1_prm_6'], ParameterRep.VALUE_OR_LABEL_OR_CHOICES) == 'monthly' # param 7 has no value so its label will be used assert ControlIOReader.param_to_str(control_param_dict['ac-1_prm_7'], ParameterRep.VALUE_OR_LABEL_OR_CHOICES ) == 'organization-defined events'
def _replace_ids_with_text(prose: str, param_rep: ParameterRep, param_dict: Dict[str, common.Parameter]) -> str: """Find all instances of param_ids in prose and replace each with corresponding parameter representation. Need to check all values in dict for a match Reject matches where the string has an adjacent alphanumeric char: param_1 and param_10 or aparam_1 """ for param in param_dict.values(): if param.id not in prose: continue # create the replacement text for the param_id param_str = ControlIOReader.param_to_str(param, param_rep) # non-capturing groups are odd in re.sub so capture all 3 groups and replace the middle one pattern = r'(^|[^a-zA-Z0-9_])' + param.id + r'($|[^a-zA-Z0-9_])' prose = re.sub(pattern, r'\1' + param_str + r'\2', prose) return prose
def test_control_failures(tmp_path: pathlib.Path) -> None: """Test various failure modes.""" part = common.Part(name='foo') assert ControlIOWriter.get_label(part) == '' assert ControlIOReader._strip_to_make_ncname('1a@foo') == 'afoo' with pytest.raises(TrestleError): ControlIOReader._strip_to_make_ncname('1@') with pytest.raises(TrestleError): ControlIOReader._indent('') with pytest.raises(TrestleError): ControlIOReader._indent(' foo')
def test_control_objective(tmp_path: pathlib.Path) -> None: """Test read and write of control with objective.""" # write the control directly as raw markdown text md_path = tmp_path / 'xy-9.md' with open(md_path, 'w') as f: f.write(control_text) # read it in as markdown to an OSCAL control in memory control, group_title = ControlIOReader.read_control(md_path, True) assert group_title == 'My Group Title' sub_dir = tmp_path / 'sub_dir' sub_dir.mkdir(exist_ok=True) # write it out as markdown in a separate directory to avoid name clash control_writer = ControlIOWriter() control_writer.write_control(sub_dir, control, 'My Group Title', None, None, False, False, None, False, None, None) # confirm the newly written markdown text is identical to what was read originally assert test_utils.text_files_equal(md_path, sub_dir / 'xy-9.md')
def read_additional_content(md_path: pathlib.Path, required_sections_list: List[str]) -> Tuple[List[prof.Alter], Dict[str, Any]]: """Read all markdown controls and return list of alters plus control param dict.""" new_alters: List[prof.Alter] = [] final_param_dict: Dict[str, Any] = {} for group_path in CatalogInterface._get_group_ids_and_dirs(md_path).values(): for control_file in CatalogInterface._get_sorted_control_paths(group_path): control_alters, control_param_dict = ControlIOReader.read_new_alters_and_params( control_file, required_sections_list ) new_alters.extend(control_alters) for param_id, param_dict in control_param_dict.items(): # if profile_values are present, overwrite values with them if const.PROFILE_VALUES in param_dict: param_dict[const.VALUES] = param_dict.pop(const.PROFILE_VALUES) final_param_dict[param_id] = param_dict return new_alters, final_param_dict
def _get_profile_param_dict( control: cat.Control, profile_param_dict: Dict[str, common.Parameter], values_only: bool ) -> Dict[str, common.Parameter]: """ Get the dict of params for this control including possible overrides made by the profile modifications. Args: control: The control being queried profile_param_dict: The full dict of params and modified values made by the profile Returns: mapping of param ids to their final parameter states after possible modify by the profile setparameters """ # get the mapping of param_id's to params for this control, excluding those with no value set param_dict = ControlIOReader.get_control_param_dict(control, values_only) for key in param_dict.keys(): if key in profile_param_dict: param_dict[key] = profile_param_dict[key] return param_dict
def _replace_params( text: str, param_dict: Dict[str, common.Parameter], params_format: Optional[str] = None, param_rep: ParameterRep = ParameterRep.VALUE_OR_LABEL_OR_CHOICES ) -> str: """ Replace params found in moustaches with values from the param_dict. A single line of prose may contain multiple moustaches. """ # first check if there are any moustache patterns in the text if param_rep == ParameterRep.LEAVE_MOUSTACHE: return text staches: List[str] = re.findall(r'{{.*?}}', text) if not staches: return text # now have list of all staches including braces, e.g. ['{{foo}}', '{{bar}}'] # clean the staches so they just have the param ids param_ids = [] for stache in staches: # remove braces so these are just param_ids but may have extra chars stache_contents = stache[2:(-2)] param_id = stache_contents.replace('insert: param,', '').strip() param_ids.append(param_id) # now replace original stache text with param values for i, _ in enumerate(staches): # A moustache may refer to a param_id not listed in the control's params if param_ids[i] not in param_dict: logger.warning( f'Control prose references param {param_ids[i]} not found in the control.' ) elif param_dict[param_ids[i]] is not None: param = param_dict[param_ids[i]] param_str = ControlIOReader.param_to_str( param, param_rep, False, False, params_format) text = text.replace(staches[i], param_str, 1) else: logger.warning( f'Control prose references param {param_ids[i]} with no specified value.' ) return text
def read_catalog_imp_reqs(md_path: pathlib.Path, avail_comps: Dict[str, ossp.SystemComponent]) -> List[ossp.ImplementedRequirement]: """Read the full set of control implemented requirements from markdown. Args: md_path: Path to the markdown control files, with directories for each group avail_comps: Dict mapping component names to known components Returns: List of implemented requirements gathered from each control Notes: As the controls are read into the catalog the needed components are added if not already available. avail_comps provides the mapping of component name to the actual component. """ imp_reqs: List[ossp.ImplementedRequirement] = [] for group_path in CatalogInterface._get_group_ids_and_dirs(md_path).values(): for control_file in CatalogInterface._get_sorted_control_paths(group_path): imp_reqs.append(ControlIOReader.read_implemented_requirement(control_file, avail_comps)) return imp_reqs
def read_catalog_from_markdown(self, md_path: pathlib.Path, set_parameters: bool) -> cat.Catalog: """ Read the groups and catalog controls from the given directory. This will overwrite the existing groups and controls in the catalog. """ if not self._catalog: self._catalog = gens.generate_sample_model(cat.Catalog) id_map = CatalogInterface._get_group_ids_and_dirs(md_path) groups: List[cat.Group] = [] # read each group dir for group_id, group_dir in id_map.items(): control_list = [] group_title = '' # Need to get group title from at least one control in this directory # All controls in dir should have same group title # Set group title to the first one found and warn if different non-empty title appears # Controls with empty group titles are tolerated but at least one title must be present or warning given # The special group with no name that has the catalog as parent is just a list and has no title for control_path in CatalogInterface._get_sorted_control_paths(group_dir): control, control_group_title = ControlIOReader.read_control(control_path, set_parameters) if control_group_title: if group_title: if control_group_title != group_title: logger.warning( f'Control {control.id} group title {control_group_title} differs from {group_title}' ) else: group_title = control_group_title control_list.append(control) if group_id: if not group_title: logger.warning(f'No group title found in controls for group {group_id}') new_group = cat.Group(id=group_id, title=group_title) new_group.controls = control_list groups.append(new_group) else: # if the list of controls has no group id it also has no title and is just the controls of the catalog self._catalog.controls = control_list self._catalog.groups = groups if groups else None return self._catalog
def write_catalog_as_markdown( self, md_path: pathlib.Path, yaml_header: dict, sections_dict: Optional[Dict[str, str]], prompt_responses: bool, additional_content: bool = False, profile: Optional[prof.Profile] = None, overwrite_header_values: bool = False, set_parameters: bool = False, required_sections: Optional[str] = None, allowed_sections: Optional[str] = None ) -> None: """ Write out the catalog controls from dict as markdown files to the specified directory. Args: md_path: Path to directory in which to write the markdown yaml_header: Dictionary to write into the yaml header of the controls sections_dict: Optional dict mapping section short names to long prompt_responses: Whether to prompt for responses in the control markdown additional_content: Should the additional content be printed corresponding to profile adds profile: Optional profile containing the adds making up additional content overwrite_header_values: Overwrite existing values in markdown header content but add new content set_parameters: Set header values based on params in the control and in the profile required_sections: Optional string containing list of sections that should be prompted for prose allowed_sections: Optional string containing list of sections that should be included in markdown Returns: None """ writer = ControlIOWriter() required_section_list = required_sections.split(',') if required_sections else [] allowed_section_list = allowed_sections.split(',') if allowed_sections else [] # create the directory in which to write the control markdown files md_path.mkdir(exist_ok=True, parents=True) catalog_interface = CatalogInterface(self._catalog) # get the list of SetParams for this profile full_profile_param_dict = CatalogInterface._get_full_profile_param_dict(profile) if profile else {} # write out the controls for control in catalog_interface.get_all_controls_from_catalog(True): # make copy of incoming yaml header new_header = copy.deepcopy(yaml_header) # here we do special handling of how set-parameters merge with the yaml header if set_parameters: # get all params for this control control_param_dict = ControlIOReader.get_control_param_dict(control, False) set_param_dict: Dict[str, str] = {} for param_id, param_dict in control_param_dict.items(): # if the param is in the profile set_params, load its contents first and mark as profile-values if param_id in full_profile_param_dict: # get the param from the profile set_param param = full_profile_param_dict[param_id] # assign its contents to the dict new_dict = ModelUtils.parameter_to_dict(param, True) profile_values = new_dict.get(const.VALUES, None) if profile_values: new_dict[const.PROFILE_VALUES] = profile_values new_dict.pop(const.VALUES) # then insert the original, incoming values as values if param_id in control_param_dict: orig_param = control_param_dict[param_id] orig_dict = ModelUtils.parameter_to_dict(orig_param, True) new_dict[const.VALUES] = orig_dict.get(const.VALUES, None) # merge contents from the two sources with priority to the profile-param for item in ['select', 'label']: if item in orig_dict and item not in new_dict: new_dict[item] = orig_dict[item] else: new_dict = ModelUtils.parameter_to_dict(param_dict, True) new_dict.pop('id') set_param_dict[param_id] = new_dict if set_param_dict: if const.SET_PARAMS_TAG not in new_header: new_header[const.SET_PARAMS_TAG] = {} if overwrite_header_values: # update the control params with new values for key, value in new_header[const.SET_PARAMS_TAG].items(): if key in control_param_dict: set_param_dict[key] = value else: # update the control params with any values in yaml header not set in control # need to maintain order in the set_param_dict for key, value in new_header[const.SET_PARAMS_TAG].items(): if key in control_param_dict and key not in set_param_dict: set_param_dict[key] = value new_header[const.SET_PARAMS_TAG] = set_param_dict elif const.SET_PARAMS_TAG in new_header: # need to cull any params that are not in control pop_list: List[str] = [] for key in new_header[const.SET_PARAMS_TAG].keys(): if key not in control_param_dict: pop_list.append(key) for pop in pop_list: new_header[const.SET_PARAMS_TAG].pop(pop) _, group_title, _ = catalog_interface.get_group_info_by_control(control.id) # control could be in sub-group of group so build path to it group_dir = md_path control_path = catalog_interface._get_control_path(control.id) for sub_dir in control_path: group_dir = group_dir / sub_dir if not group_dir.exists(): group_dir.mkdir(parents=True, exist_ok=True) writer.write_control( group_dir, control, group_title, new_header, sections_dict, additional_content, prompt_responses, profile, overwrite_header_values, required_section_list, allowed_section_list )
part_b.parts = [part_b1, part_b2] parts = [part_a, part_b] else: parts = None statement_part.parts = parts control.parts = [statement_part] if sections: control.parts.extend([sec_1, sec_2]) writer = ControlIOWriter() writer.write_control(tmp_path, control, 'My Group Title', None, None, additional_content, False, None, False, None, None) md_path = tmp_path / f'{control.id}.md' reader = ControlIOReader() new_control, group_title = reader.read_control(md_path, False) new_control.title = dummy_title assert group_title == 'My Group Title' assert len(new_control.parts) == len(control.parts) assert control.parts[0].prose == new_control.parts[0].prose assert control.parts[0].parts == new_control.parts[0].parts assert control == new_control def test_control_objective(tmp_path: pathlib.Path) -> None: """Test read and write of control with objective.""" # write the control directly as raw markdown text md_path = tmp_path / 'xy-9.md' with open(md_path, 'w') as f: f.write(control_text)
def test_control_bad_components(md_file: str) -> None: """Test loading of imp reqs for control with bad components.""" control_path = pathlib.Path('tests/data/author/controls/') / md_file with pytest.raises(TrestleError): ControlIOReader.read_all_implementation_prose_and_header(control_path)
def test_broken_yaml_header(testdata_dir: pathlib.Path) -> None: """Test for a bad markdown header.""" bad_file = testdata_dir / 'author' / 'bad_md_header.md' with pytest.raises(TrestleError): ControlIOReader._load_control_lines_and_header(bad_file)
def test_create_next_label(prev_label, next_label, indent) -> None: """Test bumping of label strings.""" assert ControlIOReader._create_next_label(prev_label, indent) == next_label
def test_bump_label(prev_label, bumped_label) -> None: """Test bumping of label strings.""" assert ControlIOReader._bump_label(prev_label) == bumped_label