diff --git a/.gitignore b/.gitignore index dac7a874c..17dcce0bd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ site tmp_bin_test .mypy_cache +/venv.trestle/ + diff --git a/docs/api_reference/trestle.core.crm.bycomp_interface.md b/docs/api_reference/trestle.core.crm.bycomp_interface.md new file mode 100644 index 000000000..a6bc38dde --- /dev/null +++ b/docs/api_reference/trestle.core.crm.bycomp_interface.md @@ -0,0 +1,2 @@ +::: trestle.core.crm.bycomp_interface +handler: python diff --git a/docs/api_reference/trestle.core.crm.export_reader.md b/docs/api_reference/trestle.core.crm.export_reader.md new file mode 100644 index 000000000..27301c488 --- /dev/null +++ b/docs/api_reference/trestle.core.crm.export_reader.md @@ -0,0 +1,2 @@ +::: trestle.core.crm.export_reader +handler: python diff --git a/docs/api_reference/trestle.core.crm.export_writer.md b/docs/api_reference/trestle.core.crm.export_writer.md new file mode 100644 index 000000000..311b57102 --- /dev/null +++ b/docs/api_reference/trestle.core.crm.export_writer.md @@ -0,0 +1,2 @@ +::: trestle.core.crm.export_writer +handler: python diff --git a/docs/api_reference/trestle.core.crm.leveraged_statements.md b/docs/api_reference/trestle.core.crm.leveraged_statements.md new file mode 100644 index 000000000..943d725f1 --- /dev/null +++ b/docs/api_reference/trestle.core.crm.leveraged_statements.md @@ -0,0 +1,2 @@ +::: trestle.core.crm.leveraged_statements +handler: python diff --git a/docs/api_reference/trestle.core.crm.ssp_inheritance_api.md b/docs/api_reference/trestle.core.crm.ssp_inheritance_api.md new file mode 100644 index 000000000..74d3c7457 --- /dev/null +++ b/docs/api_reference/trestle.core.crm.ssp_inheritance_api.md @@ -0,0 +1,2 @@ +::: trestle.core.crm.ssp_inheritance_api +handler: python diff --git a/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md b/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md index 38a11b252..a1c3e4eda 100644 --- a/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md +++ b/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md @@ -1050,6 +1050,131 @@ As with all the `assemble` tools, you may optionally specify a `--name` for a co If you do not specify component-defintions during assembly, the markdown should not refer to any components other than `This System`. Thus you may first generate markdown with `ssp-generate` and no component-definitions specified - and then you may assemble that ssp with `ssp-assemble` and no component-definitions specified - but only if there are no components other than `This System` referenced in the markdown. You may add new component implementation details to the markdown later, but any new components must be defined in a component-defintion file, and that file must be specified when `ssp-assemble` is run. +## Inheritance view + +The inheritance view is generated by setting the `--leveraged-ssp` flag with `trestle author ssp-generate`. It contains information relating to exported information such as inherited capabilities and customer responsibilities that can be used to populate the inheritance information in the assembled SSP. When used, a directory named "inheritance" is created within the markdown directory. This directory serves as a designated space for mapping inherited capabilities and responsibilities onto components in the assemble SSP and authoring satisfied statements for responsibilities. + +Example usage for creation of the markdown: + +`trestle author ssp-generate --profile my_prof --compdefs "compdef_a,compdef_b" --yaml /my_yaml_dir/header.yaml --leveraged-ssp my_provider_ssp --output my_ssp` + +In this example the leveraged ssp has previously been imported into the trestle directory, but it can be fetched from remote location. + +The generated markdown output with the inheritance view will be placed in the trestle subdirectory `my_ssp/inheritance` with a subdirectory for each component in the leveraged ssp with directories separated by control and statement id below. + +An example of this directory structure is below. + +```text +. +├── Application +│ ├── ac-1_stmt.a +│ │ └── 11111111-0000-4000-9009-001001002006.md +│ ├── ac-2.1 +│ │ └── 11111111-0000-4000-9009-001001002004.md +│ └── ac-2_stmt.a +│ └── 11111111-0000-4000-9009-002001002001_11111111-0000-4000-9009-002001002002.md +└── This System + ├── ac-1_stmt.a + │ └── 11111111-0000-4000-9009-001002002001.md + ├── ac-2.1 + │ └── 11111111-0000-4000-9009-001001002001.md + └── ac-2_stmt.a + └── 11111111-0000-4000-9009-002001001001.md +``` + +The leveraged components are used as the top level directory to allow any non-leveraged components to be easily skipped or removed. Each markdown file is named in accordance with the uuid of the exported statement to ensure statement description updates can be applied. + +There are three types of markdown files that can be generated from this process. + +The examples below demonstrate these types: + +
+ +Example of inheritance provided only markdown after ssp-generate + +```markdown +--- +x-trestle-statement: + # Add or modify leveraged SSP Statements here. + provided-uuid: 11111111-0000-4000-9009-001002002001 +x-trestle-leveraging-comp: + # Leveraged statements can be optionally associated with components in this system. + # Associate leveraged statements to Components of this system here: + - name: REPLACE_ME +--- + +# Provided Statement Description + +Customer_appropriate description of what may be inherited. +``` + +
+ +
+ +Example of inheritance customer responsibility only markdown after ssp-generate + +```markdown +--- +x-trestle-statement: + # Add or modify leveraged SSP Statements here. + responsibility-uuid: 11111111-0000-4000-9009-002001001001 +x-trestle-leveraging-comp: + # Leveraged statements can be optionally associated with components in this system. + # Associate leveraged statements to Components of this system here: + - name: REPLACE_ME +--- + +# Responsibility Statement Description + +Leveraging system's responsibilities with respect to inheriting this capability. + +# Satisfied Statement Description + + +``` + +
+ +
+ +Example of inheritance shared responsibility markdown after ssp-generate + +```markdown +--- +x-trestle-statement: + # Add or modify leveraged SSP Statements here. + provided-uuid: 11111111-0000-4000-9009-002001002001 + responsibility-uuid: 11111111-0000-4000-9009-002001002002 +x-trestle-leveraging-comp: + # Leveraged statements can be optionally associated with components in this system. + # Associate leveraged statements to Components of this system here: + - name: REPLACE_ME +--- + +# Provided Statement Description + +Consumer-appropriate description of what may be inherited. + +# Responsibility Statement Description + +Leveraging system's responsibilities with respect to inheriting this capability. + +# Satisfied Statement Description + + +``` + +
+ +Some additional information and tips about this markdown are below: + +- Do not change the statement UUIDs in the YAML header. This is used in the assembled JSON to link the statements in the leveraged SSP to the components in the leveraging SSP. +- When mapping components in the YAML header, use the component title. If you do not wish to map a component to a particular inherited capability or responsibility, just leave the file as is. Files without mapped components or that contain the default "REPLACE ME" entry will be skipped. +- If the file exists, just the editable information will be preserved when regenerating existing inheritance view markdown. This includes the information under `Satisfied Statement Description` and the mapped components in the YAML header. + +After manually editing the inheritance view markdown, the `trestle author ssp-assemble` command can be run without modifications for the inheritance view use case. During assemble, the inheritance directory is detected and the information will be assembled into the SSP. The by-component assemblies will be updated or added under existing implemented requirement or statement sections with the information from the markdown. +
diff --git a/mkdocs.yml b/mkdocs.yml index 38b112540..c4c463040 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -113,6 +113,12 @@ nav: - control_interface: api_reference/trestle.core.control_interface.md - control_reader: api_reference/trestle.core.control_reader.md - control_writer: api_reference/trestle.core.control_writer.md + - crm: + - bycomp_interface: api_reference/trestle.core.crm.bycomp_interface.md + - export_reader: api_reference/trestle.core.crm.export_reader.md + - export_writer: api_reference/trestle.core.crm.export_writer.md + - leveraged_statements: api_reference/trestle.core.crm.leveraged_statements.md + - ssp_inheritance_api: api_reference/trestle.core.crm.ssp_inheritance_api.md - docs_control_writer: api_reference/trestle.core.docs_control_writer.md - draw_io: api_reference/trestle.core.draw_io.md - duplicates_validator: api_reference/trestle.core.duplicates_validator.md diff --git a/tests/data/json/leveraging_ssp.json b/tests/data/json/leveraging_ssp.json new file mode 100644 index 000000000..e3bfb232c --- /dev/null +++ b/tests/data/json/leveraging_ssp.json @@ -0,0 +1,281 @@ +{ + "system-security-plan": { + "uuid": "bb9219b1-e51c-4680-abb0-616a43bbfbb1", + "metadata": { + "title": "Leveraging SaaS System Security Plan", + "last-modified": "2021-06-08T13:57:35.4515-04:00", + "version": "0.1", + "oscal-version": "1.0.0", + "roles": [ + { + "id": "admin", + "title": "Administrator" + } + ], + "parties": [ + { + "uuid": "22222222-0000-4000-9000-100000000001", + "type": "person" + }, + { + "uuid": "22222222-0000-4000-9000-100000000002", + "type": "person", + "remarks": "Leveraged Authorization POC" + } + ] + }, + "import-profile": { + "href": "trestle://profiles/simple_test_profile/profile.json" + }, + "system-characteristics": { + "system-ids": [ + { + "id": "saas_system_iaas_customer" + } + ], + "system-name": "Leveraging SaaS System", + "description": "An example of three customers leveraging an authorized SaaS, which is running on an authorized IaaS.\n\n```\n\nCust-A Cust-B Cust-C\n | | |\n +---------+---------+\n |\n +-------------------+\n | Leveraging SaaS |\n | this file |\n +-------------------+\n |\n |\n +-------------------+\n | Leveraged IaaS |\n +-------------------+\n \n```\n\nIn this example, the IaaS SSP specifies customer responsibilities for certain controls.\n\nThe SaaS must address these for the control to be fully satisfied.\n\nThe SaaS provider may either implement these directly or pass the responsibility on to their customers. Both may be necessary.\n\nFor any given control, the Leveraged IaaS SSP must describe:\n\n1. HOW the IaaS is directly satisfying the control\n1. WHAT responsibilities are left for the Leveraging SaaS (or their customers) to implement.\n\n\nFor any given control, the Leveraging SaaS SSP must describe:\n\n1. WHAT is being inherited from the underlying IaaS\n1. HOW the SaaS is directly satisfying the control.\n1. WHAT responsibilities are left for the SaaS customers to implement. (The SaaS customers are Cust-A, B and C)\n", + "security-sensitivity-level": "low", + "system-information": { + "information-types": [ + { + "title": "System and Network Monitoring", + "description": "This system handles information pertaining to audit events.", + "categorizations": [ + { + "system": "https://doi.org/10.6028/NIST.SP.800-60v2r1", + "information-type-ids": [ + "C.3.5.8" + ] + } + ], + "confidentiality-impact": { + "base": "fips-199-moderate", + "selected": "fips-199-low", + "adjustment-justification": "This impact has been adjusted to low as an example of how to perform this type of adjustment." + }, + "integrity-impact": { + "base": "fips-199-moderate", + "selected": "fips-199-low", + "adjustment-justification": "This impact has been adjusted to low as an example of how to perform this type of adjustment." + }, + "availability-impact": { + "base": "fips-199-moderate", + "selected": "fips-199-low", + "adjustment-justification": "This impact has been adjusted to low as an example of how to perform this type of adjustment." + } + } + ] + }, + "security-impact-level": { + "security-objective-confidentiality": "fips-199-low", + "security-objective-integrity": "fips-199-low", + "security-objective-availability": "fips-199-low" + }, + "status": { + "state": "operational" + }, + "authorization-boundary": { + "description": "The virtualized components deployed on the CSP IaaS." + }, + "remarks": "Most system-characteristics content does not support the example, and is included to meet the minimum SSP syntax requirements." + }, + "system-implementation": { + "leveraged-authorizations": [ + { + "uuid": "22222222-0000-4000-9000-300000000001", + "title": "CSP IaaS [Leveraged System]", + "links": [ + { + "href": "./oscal_leveraged-example_ssp.json", + "rel": "OSCAL-SSP-XML" + } + ], + "party-uuid": "22222222-0000-4000-9000-100000000002", + "date-authorized": "2018-01-01" + } + ], + "users": [ + { + "uuid": "22222222-0000-4000-9000-200000000001", + "role-ids": [ + "admin" + ], + "authorized-privileges": [ + { + "title": "Administrator", + "functions-performed": [ + "Manages the components within the SaaS." + ] + } + ] + } + ], + "components": [ + { + "uuid": "80511208-2643-4d2a-bef4-d593ba86b73f", + "type": "this-system", + "title": "This System", + "description": "The system described by this SSP.\n\nThis text was auto-generated by the OSCAL M3-RC1 data upgrade converter.", + "status": { + "state": "operational" + } + }, + { + "uuid": "22222222-0000-4000-9001-000000000001", + "type": "this-system", + "title": "THIS SYSTEM (SaaS)", + "description": "This Leveraging SaaS.\n\nThe entire system as depicted in the system authorization boundary", + "props": [ + { + "name": "implementation-point", + "value": "system" + } + ], + "status": { + "state": "operational" + } + }, + { + "uuid": "22222222-0000-4000-9001-000000000002", + "type": "system", + "title": " **LEVERAGED SYSTEM (IaaS)** ", + "description": "If the leveraged system owner provides a UUID for their system (such as in an OSCAL-based CRM), it should be used as the UUID for this component.", + "props": [ + { + "name": "implementation-point", + "value": "external" + }, + { + "name": "leveraged-authorization-uuid", + "value": "22222222-0000-4000-9000-300000000001" + }, + { + "name": "inherited-uuid", + "value": "11111111-0000-4000-9001-000000000001" + } + ], + "status": { + "state": "operational" + } + }, + { + "uuid": "22222222-0000-4000-9001-000000000003", + "type": "appliance", + "title": "Access Control Appliance", + "description": "An access control virtual appliance, wich performs XYZ functions.", + "props": [ + { + "name": "implementation-point", + "value": "internal" + }, + { + "name": "virtual", + "value": "yes" + } + ], + "status": { + "state": "operational" + } + }, + { + "uuid": "22222222-0000-4000-9001-000000000004", + "type": "application", + "title": "Leveraged Application", + "description": "Inherited from underlying IaaS.", + "props": [ + { + "name": "implementation-point", + "value": "external" + }, + { + "name": "leveraged-authorization-uuid", + "value": "22222222-0000-4000-9000-300000000001" + }, + { + "name": "inherited-uuid", + "value": "11111111-0000-4000-9001-000000000002" + } + ], + "status": { + "state": "operational" + } + } + ] + }, + "control-implementation": { + "description": "This is a collection of control responses.", + "implemented-requirements": [ + { + "uuid": "22222222-0000-4000-9009-002000000000", + "control-id": "ac-2", + "set-parameters": [ + { + "param-id": "ac-2_prm_1", + "values": [ + "privileged and non-privileged" + ] + } + ], + "by-components": [ + { + "component-uuid": "22222222-0000-4000-9001-000000000003", + "uuid": "22222222-0000-4000-9009-002001003000", + "description": " *duplicated/tailored description of what was inherited, and description of what was configured.* \n\nConsumer-appropriate description of what may be inherited.\n\nIn the context of the application component in satisfaction of AC-2." + } + ], + "statements": [ + { + "statement-id": "ac-2_stmt.a", + "uuid": "22222222-0000-4000-9009-002001000000", + "by-components": [ + { + "component-uuid": "22222222-0000-4000-9001-000000000001", + "uuid": "22222222-0000-4000-9009-002001001000", + "description": "Response for the \\\"This System\\\" component.\n\nOverall description of how \\\"This System\\\" satisfies AC-2, Part a.\n\nResponse for the \\\"This System\\\" component.\n\nOverall description of how \\\"This System\\\" satisfies AC-2, Part a.\n\nResponse for the \\\"This System\\\" component.\n\nOverall description of how \\\"This System\\\" satisfies AC-2, Part a.\n\nResponse for the \\\"This System\\\" component.\n\nOverall description of how \\\"This System\\\" satisfies AC-2, Part a.", + "props": [ + { + "name": "responsibility", + "value": "provider" + } + ] + }, + { + "component-uuid": "22222222-0000-4000-9001-000000000002", + "uuid": "22222222-0000-4000-9009-002001002000", + "description": "Describe how this internal virtual appliance satisfies AC-2, Part a.", + "satisfied": [ + { + "uuid": "22222222-0000-4000-9009-002001002001", + "responsibility-uuid": "11111111-0000-4000-9009-002001001001", + "description": "Description that directly addresses how the consumer responsibility was satisfied.", + "responsible-roles": [ + { + "role-id": "role-id" + } + ] + } + ] + } + ], + "remarks": "a. Identifies and selects the following types of information system accounts to support organizational missions/business functions: [Assignment: privileged and non-privileged];" + } + ], + "remarks": "The organization:\n\na. Identifies and selects the following types of information system accounts to support organizational missions/business functions: [Assignment: organization-defined information system account types];\n\nb. Assigns account managers for information system accounts;\n\nc. Establishes conditions for group and role membership;\n\nOmitted: d. through j." + } + ] + }, + "back-matter": { + "resources": [ + { + "uuid": "22222222-0000-4000-9999-000000000001", + "rlinks": [ + { + "href": "./attachments/SaaS_ac_proc.docx" + } + ] + } + ] + } + } + } \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py index f842e937a..50244db2f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -243,6 +243,20 @@ def replace_line_in_file_after_tag(file_path: pathlib.Path, tag: str, new_line: return False +def replace_in_file(file_path: pathlib.Path, search_text: str, replace_text: str) -> None: + """Replace all occurrences of search_text with replace_text in file_path.""" + if not file_path.exists(): + raise TrestleError(f'Test file {file_path} not found.') + + with open(file_path, 'r') as file: + file_content = file.read() + + updated_content = file_content.replace(search_text, replace_text) + + with open(file_path, 'w') as file: + file.write(updated_content) + + def substitute_text_in_file(file_path: pathlib.Path, tag: str, new_str: str) -> bool: """Substitute first match of string with new string in file.""" if not file_path.exists(): @@ -442,10 +456,13 @@ def load_from_json( shutil.copy2(src_path, dst_path) -def setup_for_ssp(tmp_trestle_dir: pathlib.Path, - prof_name: str, - output_name: str, - use_yaml: bool = False) -> Tuple[argparse.Namespace, pathlib.Path]: +def setup_for_ssp( + tmp_trestle_dir: pathlib.Path, + prof_name: str, + output_name: str, + use_yaml: bool = False, + leveraged_ssp_name: str = '' +) -> Tuple[argparse.Namespace, pathlib.Path]: """Create the comp_def, profile and catalog content needed for ssp-generate.""" comp_names = 'comp_def_a,comp_def_b' for comp_name in comp_names.split(','): @@ -456,10 +473,15 @@ def setup_for_ssp(tmp_trestle_dir: pathlib.Path, load_from_json(tmp_trestle_dir, local_prof_name, local_prof_name, prof.Profile) load_from_json(tmp_trestle_dir, 'simplified_nist_catalog', 'simplified_nist_catalog', cat.Catalog) yaml_path = YAML_TEST_DATA_PATH / 'good_simple.yaml' if use_yaml else None + + if leveraged_ssp_name: + load_from_json(tmp_trestle_dir, leveraged_ssp_name, leveraged_ssp_name, ssp.SystemSecurityPlan) + args = argparse.Namespace( trestle_root=tmp_trestle_dir, profile=prof_name, compdefs=comp_names, + leveraged_ssp=leveraged_ssp_name, output=output_name, verbose=0, overwrite_header_values=False, @@ -638,6 +660,77 @@ def gen_and_assemble_first_ssp(prof_name: str, ssp_name: str, gen_args: Any, mon execute_command_and_assert(ssp_assemble, 0, monkeypatch) +def generate_test_by_comp() -> ssp.ByComponent: + """Generate a by-component assembly for testing.""" + by_comp = generators.generate_sample_model(ssp.ByComponent) + by_comp.export = generators.generate_sample_model(ssp.Export) + by_comp.export.provided = [] + by_comp.export.responsibilities = [] + + isolated_provided = generators.generate_sample_model(ssp.Provided) + isolated_responsibility = generators.generate_sample_model(ssp.Responsibility) + + set_provided = generators.generate_sample_model(ssp.Provided) + set_responsibility = generators.generate_sample_model(ssp.Responsibility) + + set_responsibility.provided_uuid = set_provided.uuid + + by_comp.export.provided.append(isolated_provided) + by_comp.export.provided.append(set_provided) + by_comp.export.responsibilities.append(isolated_responsibility) + by_comp.export.responsibilities.append(set_responsibility) + + return by_comp + + +def generate_test_inheritance_md( + provided_uuid: str, responsibility_uuid: str, leveraged_statement_names: List[str], leveraged_ssp_href: str +) -> str: + """ + Generate a inheritance statement with placeholders replaced by provided values. + + Args: + provided_uuid (str): UUID for provided statement. + responsibility_uuid (str): UUID for responsibility statement. + leveraged_statement_names (list of str): Names for leveraged statements (as a list). + leveraged_ssp_href (str): Href for leveraged SSP. + + Returns: + str: The template with placeholders replaced. + """ + # Convert the list of leveraged statement names into a YAML list + leveraged_statement_list = '\n'.join([f' - name: {name}' for name in leveraged_statement_names]) + + md_template = f"""--- +x-trestle-statement: + # Add or modify leveraged SSP Statements here. + provided-uuid: {provided_uuid} + responsibility-uuid: {responsibility_uuid} +x-trestle-leveraging-comp: + # Leveraged statements can be optionally associated with components in this system. + # Associate leveraged statements to Components of this system here: +{leveraged_statement_list} +x-trestle-global: + leveraged-ssp: + href: {leveraged_ssp_href} +--- + +# Provided Statement Description + +provided statement description + +# Responsibility Statement Description + +resp statement description + +# Satisfied Statement Description + + +My Satisfied Description + """ + return md_template + + class FileChecker: """Check for changes in files after test operations.""" diff --git a/tests/trestle/core/commands/author/ssp_test.py b/tests/trestle/core/commands/author/ssp_test.py index a41dac384..6f2646a14 100644 --- a/tests/trestle/core/commands/author/ssp_test.py +++ b/tests/trestle/core/commands/author/ssp_test.py @@ -255,6 +255,107 @@ def test_ssp_generate_header_edit(tmp_trestle_dir: pathlib.Path) -> None: assert len(co) == 3 +def test_ssp_generate_with_inheritance(tmp_trestle_dir: pathlib.Path) -> None: + """Test ssp-generate with inheritance view.""" + args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name, False, 'leveraged_ssp') + ssp_cmd = SSPGenerate() + assert ssp_cmd._run(args) == 0 + + # Test output for each type of file + + # Find export files under This System + this_system_dir = tmp_trestle_dir / ssp_name / const.INHERITANCE_VIEW_DIR / 'This System' + + expected_uuid = '11111111-0000-4000-9009-001001002001' + ac_21 = this_system_dir / 'ac-2.1' + test_provided = ac_21 / f'{expected_uuid}.md' + assert test_provided.exists() + + # confirm content in yaml header + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(test_provided) + assert tree is not None + + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'REPLACE_ME'}] + assert header[const.TRESTLE_STATEMENT_TAG][const.PROVIDED_UUID] == expected_uuid + + expected_provided = """# Provided Statement Description + +Consumer-appropriate description of what may be inherited. + +In the context of the application component in satisfaction of AC-2.1.""" + + # Confirm markdown content + node = tree.get_node_for_key(const.PROVIDED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == expected_provided + + expected_uuid = '11111111-0000-4000-9009-002001001001' + ac_2_stm = this_system_dir / 'ac-2_stmt.a' + test_provided = ac_2_stm / f'{expected_uuid}.md' + assert test_provided.exists() + + # confirm content in yaml header + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(test_provided) + assert tree is not None + + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'REPLACE_ME'}] + assert header[const.TRESTLE_STATEMENT_TAG][const.RESPONSIBILITY_UUID] == expected_uuid + + expected_responsibility = """# Responsibility Statement Description + +Leveraging system's responsibilities with respect to inheriting this capability. + +In the context of the application component in satisfaction of AC-2, part a. +""" + + # Confirm markdown content + node = tree.get_node_for_key(const.RESPONSIBILITY_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == expected_responsibility + + # Fine export files under Application + application_dir = tmp_trestle_dir / ssp_name / const.INHERITANCE_VIEW_DIR / 'Application' + + expected_provided_uuid = '11111111-0000-4000-9009-002001002001' + expected_responsibility_uuid = '11111111-0000-4000-9009-002001002002' + ac_2_stm = application_dir / 'ac-2_stmt.a' + test_provided = ac_2_stm / f'{expected_provided_uuid}_{expected_responsibility_uuid}.md' + assert test_provided.exists() + + # confirm content in yaml header + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(test_provided) + assert tree is not None + + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'REPLACE_ME'}] + assert header[const.TRESTLE_STATEMENT_TAG][const.PROVIDED_UUID] == expected_provided_uuid + assert header[const.TRESTLE_STATEMENT_TAG][const.RESPONSIBILITY_UUID] == expected_responsibility_uuid + + expected_provided = """# Provided Statement Description + +Consumer-appropriate description of what may be inherited. + +In the context of the application component in satisfaction of AC-2, part a. +""" + + expected_responsibility = """# Responsibility Statement Description + +Leveraging system's responsibilities with respect to inheriting this capability. + +In the context of the application component in satisfaction of AC-2, part a. +""" + + # Confirm markdown content + node = tree.get_node_for_key(const.PROVIDED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == expected_provided + + node = tree.get_node_for_key(const.RESPONSIBILITY_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == expected_responsibility + + def test_ssp_assemble(tmp_trestle_dir: pathlib.Path) -> None: """Test ssp assemble from cli.""" gen_args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name) @@ -446,6 +547,49 @@ def test_ssp_generate_resolved_catalog(tmp_trestle_dir: pathlib.Path) -> None: resolved_catalog.oscal_write(new_catalog_path) +def test_ssp_assemble_with_inheritance(tmp_trestle_dir: pathlib.Path) -> None: + """Test ssp assemble from cli with inheritance view.""" + gen_args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name, False, 'leveraged_ssp') + args_compdefs = gen_args.compdefs + + # first create the markdown + ssp_gen = SSPGenerate() + assert ssp_gen._run(gen_args) == 0 + + this_system_dir = tmp_trestle_dir / ssp_name / const.INHERITANCE_VIEW_DIR / 'This System' + + expected_uuid = '11111111-0000-4000-9009-001001002001' + ac_21 = this_system_dir / 'ac-2.1' + test_provided = ac_21 / f'{expected_uuid}.md' + + test_utils.replace_in_file(test_provided, 'REPLACE_ME', 'comp_aa') + + # now assemble the edited controls into json ssp + ssp_assemble = SSPAssemble() + args = argparse.Namespace( + trestle_root=tmp_trestle_dir, + markdown=ssp_name, + output=ssp_name, + verbose=0, + regenerate=False, + name=None, + compdefs=args_compdefs, + version=None + ) + assert ssp_assemble._run(args) == 0 + + ssp, _ = ModelUtils.load_model_for_class(tmp_trestle_dir, ssp_name, ossp.SystemSecurityPlan, FileContentType.JSON) + + imp_reqs = ssp.control_implementation.implemented_requirements + imp_req = next((i_req for i_req in imp_reqs if i_req.control_id == 'ac-2.1'), None) + inherited = imp_req.by_components[1].inherited[0] # type: ignore + assert inherited.description == ( + 'Consumer-appropriate description of what may be inherited.\n\n\ +In the context of the application component in satisfaction of AC-2.1.' + ) + assert inherited.provided_uuid == expected_uuid + + def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None: """Test the ssp filter.""" # FIXME enhance coverage diff --git a/tests/trestle/core/crm/__init__.py b/tests/trestle/core/crm/__init__.py new file mode 100644 index 000000000..4223677d9 --- /dev/null +++ b/tests/trestle/core/crm/__init__.py @@ -0,0 +1,16 @@ +# -*- mode:python; coding:utf-8 -*- + +# Copyright (c) 2020 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests crm package.""" diff --git a/tests/trestle/core/crm/bycomp_interface_test.py b/tests/trestle/core/crm/bycomp_interface_test.py new file mode 100644 index 000000000..767d99685 --- /dev/null +++ b/tests/trestle/core/crm/bycomp_interface_test.py @@ -0,0 +1,109 @@ +# -*- mode:python; coding:utf-8 -*- +# Copyright (c) 2020 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the ByComponentInterface class.""" + +from tests import test_utils + +import trestle.core.generators as gens +import trestle.oscal.ssp as ossp +from trestle.core.crm.bycomp_interface import ByComponentInterface + +test_provided_uuid = '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' +test_responsibility_uuid = '4b34c68f-75fa-4b38-baf0-e50158c13ac2' + + +def test_get_isolated_responsibilities() -> None: + """Test retrieving isolated responsibilities statements.""" + by_comp: ossp.ByComponent = test_utils.generate_test_by_comp() + expected_responsibility = 1 + expected_uuid = by_comp.export.responsibilities[0].uuid # type: ignore + + bycomp_interface: ByComponentInterface = ByComponentInterface(by_comp) + + result = bycomp_interface.get_isolated_responsibilities() + + assert len(result) == expected_responsibility + assert result[0].uuid == expected_uuid + + +def test_get_isolated_provided() -> None: + """Test retrieving isolated provided statements.""" + by_comp: ossp.ByComponent = test_utils.generate_test_by_comp() + expected_provided = 1 + expected_uuid = by_comp.export.provided[0].uuid # type: ignore + + bycomp_interface: ByComponentInterface = ByComponentInterface(by_comp) + + result = bycomp_interface.get_isolated_provided() + + assert len(result) == expected_provided + assert result[0].uuid == expected_uuid + + +def test_get_export_sets() -> None: + """Test retrieving export set statements.""" + by_comp: ossp.ByComponent = test_utils.generate_test_by_comp() + expected_set = 1 + expected_responsibility_uuid = by_comp.export.responsibilities[1].uuid # type: ignore + expected_provided_uuid = by_comp.export.provided[1].uuid # type: ignore + + bycomp_interface: ByComponentInterface = ByComponentInterface(by_comp) + + result = bycomp_interface.get_export_sets() + + result_set = result[0] + + assert len(result) == expected_set + assert result_set[0].uuid == expected_responsibility_uuid + assert result_set[0].provided_uuid == expected_provided_uuid + assert result_set[1].uuid == expected_provided_uuid + + +def test_reconcile_inheritance_by_component() -> None: + """Test retrieving isolated responsibilities statements.""" + by_comp: ossp.ByComponent = test_utils.generate_test_by_comp() + by_comp.inherited = [] + by_comp.satisfied = [] + + # Set up default inherited and satisfied statements + inherited = gens.generate_sample_model(ossp.Inherited) + inherited.provided_uuid = test_provided_uuid + inherited.description = 'inherited description' + satisfied = gens.generate_sample_model(ossp.Satisfied) + satisfied.responsibility_uuid = test_responsibility_uuid + satisfied.description = 'satisfied description' + + by_comp.inherited.append(inherited) + by_comp.satisfied.append(satisfied) + + bycomp_interface: ByComponentInterface = ByComponentInterface(by_comp) + + # Create new inherited and satisfied statements and update the description + new_inherited = gens.generate_sample_model(ossp.Inherited) + new_inherited.provided_uuid = test_provided_uuid + new_inherited.description = 'new inherited description' + new_satisfied = gens.generate_sample_model(ossp.Satisfied) + new_satisfied.responsibility_uuid = test_responsibility_uuid + new_satisfied.description = 'new satisfied description' + + result_by_comp = bycomp_interface.reconcile_inheritance_by_component([new_inherited], [new_satisfied]) + + # Ensure that the resulting by_component has one of each statement and the uuids match the originals + assert len(result_by_comp.inherited) == 1 + assert len(result_by_comp.satisfied) == 1 + assert result_by_comp.inherited[0].uuid == inherited.uuid # type: ignore + assert result_by_comp.satisfied[0].uuid == satisfied.uuid # type: ignore + assert result_by_comp.inherited[0].description == new_inherited.description # type: ignore + assert result_by_comp.satisfied[0].description == new_satisfied.description # type: ignore diff --git a/tests/trestle/core/crm/export_reader_test.py b/tests/trestle/core/crm/export_reader_test.py new file mode 100644 index 000000000..d72457a3b --- /dev/null +++ b/tests/trestle/core/crm/export_reader_test.py @@ -0,0 +1,382 @@ +# Copyright (c) 2020 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the ssp_generator module.""" + +import pathlib +import uuid + +import pytest + +from tests import test_utils + +import trestle.common.const as const +import trestle.core.crm.export_reader as exportreader +import trestle.core.generators as gens +import trestle.oscal.ssp as ossp +from trestle.common.err import TrestleError +from trestle.common.model_utils import ModelUtils +from trestle.core.models.file_content_type import FileContentType + +leveraged_ssp = 'leveraged_ssp' +leveraging_ssp = 'my_ssp' + +expected_appliance_uuid = '22222222-0000-4000-9001-000000000003' +expected_saas_uuid = '22222222-0000-4000-9001-000000000001' + +example_provided_uuid = '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' +example_responsibility_uuid = '4b34c68f-75fa-4b38-baf0-e50158c13ac2' + + +@pytest.fixture(scope='function') +def sample_implemented_requirement() -> ossp.ImplementedRequirement: + """Return a valid ComponentDefinition object with some contents.""" + # one component has no properties - the other has two + impl_req: ossp.ImplementedRequirement = gens.generate_sample_model(ossp.ImplementedRequirement) + by_comp: ossp.ByComponent = gens.generate_sample_model(ossp.ByComponent) + impl_req.by_components = [by_comp] + return impl_req + + +def prep_inheritance_dir(ac_appliance_dir: pathlib.Path, inheritance_text: str) -> None: + """Prepare inheritance directory with basic information.""" + ac_2 = ac_appliance_dir.joinpath('ac-2') + ac_2.mkdir(parents=True) + + file = ac_2 / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(inheritance_text) + + # test with a statement + ac_2a = ac_appliance_dir.joinpath('ac-2_smt.a') + ac_2a.mkdir(parents=True) + + file = ac_2a / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(inheritance_text) + + +def test_read_exports_from_markdown(tmp_trestle_dir: pathlib.Path) -> None: + """Test exports reader with inheritance view.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + ac_appliance_dir = inheritance_path.joinpath('Access Control Appliance') + inheritance_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance', 'THIS SYSTEM (SaaS)'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + prep_inheritance_dir(ac_appliance_dir, inheritance_text) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + reader = exportreader.ExportReader(inheritance_path, orig_ssp) + ssp = reader.read_exports_from_markdown() + + implemented_requirements = ssp.control_implementation.implemented_requirements + + assert implemented_requirements[0].control_id == 'ac-2' + assert implemented_requirements[0].by_components[0].component_uuid == expected_appliance_uuid # type: ignore + + by_comp = implemented_requirements[0].by_components[0] # type: ignore + + assert by_comp.inherited[0].provided_uuid == '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' # type: ignore + assert by_comp.satisfied[0].responsibility_uuid == '4b34c68f-75fa-4b38-baf0-e50158c13ac2' # type: ignore + assert by_comp.satisfied[0].description == 'My Satisfied Description' # type: ignore + + assert implemented_requirements[0].by_components[1].component_uuid == expected_saas_uuid # type: ignore + by_comp = implemented_requirements[0].by_components[1] # type: ignore + + assert by_comp.inherited[0].provided_uuid == '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' # type: ignore + assert by_comp.satisfied[0].responsibility_uuid == '4b34c68f-75fa-4b38-baf0-e50158c13ac2' # type: ignore + assert by_comp.satisfied[0].description == 'My Satisfied Description' # type: ignore + + # Ensure that the statement is also added to the SSP + assert implemented_requirements[0].statements is not None + assert implemented_requirements[0].statements[0].statement_id == 'ac-2_smt.a' + + +def test_read_inheritance_markdown_dir(tmp_trestle_dir: pathlib.Path) -> None: + """Test reading inheritance view directory.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + ac_appliance_dir = inheritance_path.joinpath('Access Control Appliance') + inheritance_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance', 'THIS SYSTEM (SaaS)'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + prep_inheritance_dir(ac_appliance_dir, inheritance_text) + + unmapped_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=[const.REPLACE_ME], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + + ac_21 = ac_appliance_dir.joinpath('ac-2.1') + ac_21.mkdir(parents=True) + # Ensure this file does not get added to the dictionary + file = ac_21 / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(unmapped_text) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + reader = exportreader.ExportReader(inheritance_path, orig_ssp) + markdown_dict: exportreader.InheritanceViewDict = reader._read_inheritance_markdown_directory() + + assert len(markdown_dict) == 3 + assert 'ac-2' in markdown_dict + assert len(markdown_dict['ac-2']) == 2 + assert expected_appliance_uuid in markdown_dict['ac-2'] + + assert len(markdown_dict['ac-2.1']) == 0 + + inheritance_info = markdown_dict['ac-2'][expected_appliance_uuid] + + assert inheritance_info[0][0].provided_uuid == '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' + assert inheritance_info[1][0].responsibility_uuid == '4b34c68f-75fa-4b38-baf0-e50158c13ac2' + assert inheritance_info[1][0].description == 'My Satisfied Description' + + +def test_read_inheritance_markdown_dir_with_multiple_leveraged_components(tmp_trestle_dir: pathlib.Path) -> None: + """Test reading inheritance view directory with components that span multiple leveraged components.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + + ac_appliance_dir = inheritance_path.joinpath('Access Control Appliance') + inheritance_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance', 'THIS SYSTEM (SaaS)'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + prep_inheritance_dir(ac_appliance_dir, inheritance_text) + + inheritance_text_2 = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + + unmapped_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=[const.REPLACE_ME], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + + this_system_dir = inheritance_path.joinpath('This System') + ac_2 = this_system_dir.joinpath('ac-2') + ac_2.mkdir(parents=True) + + file = ac_2 / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(inheritance_text_2) + + ac_2a = this_system_dir.joinpath('ac-2_smt.a') + ac_2a.mkdir(parents=True) + + file = ac_2a / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(unmapped_text) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + reader = exportreader.ExportReader(inheritance_path, orig_ssp) + markdown_dict: exportreader.InheritanceViewDict = reader._read_inheritance_markdown_directory() + + assert len(markdown_dict) == 2 + assert 'ac-2' in markdown_dict + assert len(markdown_dict['ac-2']) == 2 + + assert expected_appliance_uuid in markdown_dict['ac-2'] + inheritance_info = markdown_dict['ac-2'][expected_appliance_uuid] + + assert len(inheritance_info[0]) == 2 + assert len(inheritance_info[1]) == 2 + + assert 'ac-2_smt.a' in markdown_dict + assert len(markdown_dict['ac-2_smt.a']) == 2 + + assert expected_appliance_uuid in markdown_dict['ac-2_smt.a'] + inheritance_info = markdown_dict['ac-2_smt.a'][expected_appliance_uuid] + + # Only leveraging from one component + assert len(inheritance_info[0]) == 1 + assert len(inheritance_info[1]) == 1 + + +def test_read_inheritance_markdown_dir_with_invalid_mapping(tmp_trestle_dir: pathlib.Path) -> None: + """Test reading inheritance view directory with a component that does not exist.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + + invalid_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Invalid Component'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + + this_system_dir = inheritance_path.joinpath('This System') + ac_2 = this_system_dir.joinpath('ac-2') + ac_2.mkdir(parents=True) + + file = ac_2 / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(invalid_text) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + with pytest.raises(TrestleError): + reader = exportreader.ExportReader(inheritance_path, orig_ssp) + _ = reader._read_inheritance_markdown_directory() + + +def test_get_leveraged_ssp_reference(tmp_trestle_dir: pathlib.Path) -> None: + """Test retrieving leveraged SSP reference from Markdown.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + + ac_appliance_dir = inheritance_path.joinpath('Access Control Appliance') + inheritance_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance', 'THIS SYSTEM (SaaS)'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + prep_inheritance_dir(ac_appliance_dir, inheritance_text) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + reader = exportreader.ExportReader(inheritance_path, orig_ssp) + assert reader.get_leveraged_ssp_href() == 'trestle://leveraged_ssp.json' + + +def test_get_leveraged_components(tmp_trestle_dir: pathlib.Path) -> None: + """Test leveraged mapped components from Markdown.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + + ac_appliance_dir = inheritance_path.joinpath('Access Control Appliance') + unmapped_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=[const.REPLACE_ME], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + prep_inheritance_dir(ac_appliance_dir, unmapped_text) + + this_system_dir = inheritance_path.joinpath('This System') + ac_2 = this_system_dir.joinpath('ac-2') + ac_2.mkdir(parents=True) + + inheritance_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance', 'THIS SYSTEM (SaaS)'], + leveraged_ssp_href='trestle://leveraged_ssp.json' + ) + + file = ac_2 / f'{expected_appliance_uuid}.md' + with open(file, 'w') as f: + f.write(inheritance_text) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + reader = exportreader.ExportReader(inheritance_path, orig_ssp) + _ = reader.read_exports_from_markdown() + + leveraged_components = reader.get_leveraged_components() + + assert len(leveraged_components) == 1 + assert 'Access Control Appliance' not in leveraged_components + assert 'This System' in leveraged_components + + +def test_update_type_with_by_comp(sample_implemented_requirement: ossp.ImplementedRequirement) -> None: + """Test update type with by component.""" + test_ssp: ossp.SystemSecurityPlan = gens.generate_sample_model(ossp.SystemSecurityPlan) + reader = exportreader.ExportReader('', test_ssp) + + test_inherited: ossp.Inherited = gens.generate_sample_model(ossp.Inherited) + test_satisfied: ossp.Satisfied = gens.generate_sample_model(ossp.Satisfied) + + test_comp_uuid = str(uuid.uuid4()) + + test_by_comp_dict: exportreader.ByComponentDict = {test_comp_uuid: ([test_inherited], [test_satisfied])} + + assert len(sample_implemented_requirement.by_components) == 1 + + reader._update_type_with_by_comp(sample_implemented_requirement, test_by_comp_dict) + + # Ensure a new by_comp was added, but the original was not removed + assert len(sample_implemented_requirement.by_components) == 2 + + # Test update the existing without adding a new component + test_satisfied.description = 'Updated Description' + test_by_comp_dict: exportreader.ByComponentDict = {test_comp_uuid: ([test_inherited], [test_satisfied])} + reader._update_type_with_by_comp(sample_implemented_requirement, test_by_comp_dict) + + assert len(sample_implemented_requirement.by_components) == 2 + new_by_comp = sample_implemented_requirement.by_components[1] # type: ignore + + assert new_by_comp.component_uuid == test_comp_uuid + assert new_by_comp.satisfied is not None + assert new_by_comp.satisfied[0].description == 'Updated Description' + + # Test removing the existing inheritance info + test_by_comp_dict: exportreader.ByComponentDict = {} + reader._update_type_with_by_comp(sample_implemented_requirement, test_by_comp_dict) + + new_by_comp = sample_implemented_requirement.by_components[1] # type: ignore + + assert new_by_comp.component_uuid == test_comp_uuid + assert new_by_comp.satisfied is None + assert new_by_comp.inherited is None diff --git a/tests/trestle/core/crm/export_writer_test.py b/tests/trestle/core/crm/export_writer_test.py new file mode 100644 index 000000000..0c4dbfeca --- /dev/null +++ b/tests/trestle/core/crm/export_writer_test.py @@ -0,0 +1,131 @@ +# -*- mode:python; coding:utf-8 -*- +# Copyright (c) 2020 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the ExportWriter class.""" + +import os +import pathlib +from typing import List, Tuple +from unittest.mock import Mock, patch + +import pytest + +from tests import test_utils + +import trestle.core.generators as gens +import trestle.oscal.ssp as ossp +from trestle.common.err import TrestleError +from trestle.common.model_utils import ModelUtils +from trestle.core.crm.bycomp_interface import ByComponentInterface +from trestle.core.crm.export_writer import ExportWriter +from trestle.core.crm.leveraged_statements import ( + LeveragedStatements, + StatementProvided, + StatementResponsibility, + StatementTree, +) +from trestle.core.models.file_content_type import FileContentType + +test_profile = 'simple_test_profile' +test_ssp = 'leveraged_ssp' +test_ref = 'trestle://leveraged_ssp.json' + + +def custom_side_effect(file_path: pathlib.Path) -> None: + """Write a test file.""" + with open(file_path, 'w') as file: + file.write('test') + + +def test_write_exports_as_markdown(tmp_trestle_dir: pathlib.Path) -> None: + """Test happy path for writing markdown with a mock LeveragedStatement.""" + _ = test_utils.setup_for_inherit(tmp_trestle_dir, test_profile, '', test_ssp) + ssp, _ = ModelUtils.load_model_for_class(tmp_trestle_dir, test_ssp, ossp.SystemSecurityPlan, FileContentType.JSON) + + inherited_path = tmp_trestle_dir.joinpath('inherited') + writer = ExportWriter(inherited_path, ssp, test_ref) + + mock = Mock(spec=LeveragedStatements) + mock.write_statement_md.side_effect = custom_side_effect + + with patch('trestle.core.crm.export_writer.ExportWriter._statement_types_from_exports') as mock_process: + return_value: List[Tuple[str, LeveragedStatements]] = {'filepath': mock} + mock_process.return_value = return_value + writer.write_exports_as_markdown() + + assert os.path.exists(inherited_path.joinpath('This System', 'ac-2.1', 'filepath.md')) + # Check that directory are not created when no exports exists + assert not os.path.exists(inherited_path.joinpath('Application', 'ac2')) + + +def test_write_exports_as_markdown_invalid_ssp(tmp_trestle_dir: pathlib.Path) -> None: + """Test triggering an error with an invalid SSP input.""" + _ = test_utils.setup_for_inherit(tmp_trestle_dir, test_profile, '', test_ssp) + ssp, _ = ModelUtils.load_model_for_class(tmp_trestle_dir, test_ssp, ossp.SystemSecurityPlan, FileContentType.JSON) + + # Delete a component that is used to create an invalid SSP + ssp.system_implementation.components.remove(ssp.system_implementation.components[2]) + + inherited_path = tmp_trestle_dir.joinpath('inherited') + writer = ExportWriter(inherited_path, ssp, test_ref) + + with pytest.raises(TrestleError, match=r'Component .* is not in the system implementation'): + writer.write_exports_as_markdown() + + +def test_statement_types_from_exports(tmp_trestle_dir: pathlib.Path) -> None: + """Test generated LeveragedStatements and filenames with SSP input.""" + expected_provided = 1 + expected_responsibility = 1 + expected_set = 1 + + ssp = gens.generate_sample_model(ossp.SystemSecurityPlan) + + inherited_path = tmp_trestle_dir.joinpath('inherited') + writer = ExportWriter(inherited_path, ssp, test_ref) + + by_comp: ossp.ByComponent = test_utils.generate_test_by_comp() + bycomp_interface: ByComponentInterface = ByComponentInterface(by_comp) + + result_leveraged_statements = writer._statement_types_from_exports(bycomp_interface) + provided: List[StatementProvided] = [] + responsibility: List[StatementResponsibility] = [] + sets: List[StatementTree] = [] + + for leveraged_stm in result_leveraged_statements.values(): + if isinstance(leveraged_stm, StatementProvided): + provided.append(leveraged_stm) + elif isinstance(leveraged_stm, StatementResponsibility): + responsibility.append(leveraged_stm) + elif isinstance(leveraged_stm, StatementTree): + sets.append(leveraged_stm) + + assert len(provided) == expected_provided + assert len(responsibility) == expected_responsibility + assert len(sets) == expected_set + + +def test_statement_types_no_exports(tmp_trestle_dir: pathlib.Path) -> None: + """Test generated LeveragedStatements and filenames with no exports.""" + ssp = gens.generate_sample_model(ossp.SystemSecurityPlan) + + inherited_path = tmp_trestle_dir.joinpath('inherited') + writer = ExportWriter(inherited_path, ssp, test_ref) + + by_comp = gens.generate_sample_model(ossp.ByComponent) + bycomp_interface: ByComponentInterface = ByComponentInterface(by_comp) + + result_leveraged_statements = writer._statement_types_from_exports(bycomp_interface) + + assert len(result_leveraged_statements) == 0 diff --git a/tests/trestle/core/crm/leveraged_statements_test.py b/tests/trestle/core/crm/leveraged_statements_test.py new file mode 100644 index 000000000..4edd64885 --- /dev/null +++ b/tests/trestle/core/crm/leveraged_statements_test.py @@ -0,0 +1,277 @@ +# -*- mode:python; coding:utf-8 -*- + +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for control input output methods.""" + +import pathlib +from typing import Any, Dict + +import trestle.common.const as const +from trestle.core.crm.leveraged_statements import ( + InheritanceMarkdownReader, StatementProvided, StatementResponsibility, StatementTree +) +from trestle.core.markdown.markdown_api import MarkdownAPI +from trestle.core.markdown.md_writer import MDWriter + +provided_uuid = '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' +provided_statement_desc = 'provided statement description' +resp_uuid = '4b34c68f-75fa-4b38-baf0-e50158c13ac2' +resp_statement_desc = 'resp statement description' +satisfied_statement_desc = 'satisfied statement description' + +test_href = 'trestle://ssp/ssp.json' + + +def add_authored_content(test_file: pathlib.Path, yaml_header: Dict[str, Any]) -> None: + """Update the yaml header with a test component and satisfied description to simulate editing.""" + md_writer = MDWriter(test_file) + yaml_header[const.TRESTLE_LEVERAGING_COMP_TAG] = [{'name': 'My_Comp'}] + md_writer.add_yaml_header(yaml_header) + md_writer.new_header(level=1, title=const.SATISFIED_STATEMENT_DESCRIPTION) + md_writer.new_line(const.SATISFIED_STATEMENT_COMMENT) + md_writer.new_line('My Satisfied Description') + md_writer.write_out() + + +def test_write_inheritance_tree(tmp_path: pathlib.Path) -> None: + """Test writing statements with both provided and responsibility.""" + statement_tree_path = tmp_path.joinpath('statement_tree.md') + + statement = StatementTree(provided_uuid, provided_statement_desc, resp_uuid, resp_statement_desc, test_href) + + statement.write_statement_md(statement_tree_path) + + # confirm content in yaml header + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(statement_tree_path) + assert tree is not None + + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'REPLACE_ME'}] + + assert header[const.TRESTLE_STATEMENT_TAG][const.PROVIDED_UUID] == provided_uuid + assert header[const.TRESTLE_STATEMENT_TAG][const.RESPONSIBILITY_UUID] == resp_uuid + assert header[const.TRESTLE_GLOBAL_TAG][const.LEVERAGED_SSP][const.HREF] == test_href + + # Confirm markdown content + node = tree.get_node_for_key(const.PROVIDED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == '# Provided Statement Description\n\nprovided statement description\n' + node = tree.get_node_for_key(const.RESPONSIBILITY_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == '# Responsibility Statement Description\n\nresp statement description\n' + node = tree.get_node_for_key(const.SATISFIED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == ( + '# Satisfied Statement Description\n\n' + ) + + # Update the component mapping and run again to make sure it persists + add_authored_content(statement_tree_path, header) + + statement.write_statement_md(statement_tree_path) + + # Reread the Markdown + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(statement_tree_path) + + # Ensure My_Comp and satisfied description persists + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'My_Comp'}] + node = tree.get_node_for_key(const.SATISFIED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == """# Satisfied Statement Description\n +\nMy Satisfied Description""" + + +def test_write_inheritance_provided(tmp_path: pathlib.Path) -> None: + """Test writing statements with only provided.""" + statement_provided_path = tmp_path.joinpath('statement_provided.md') + + statement = StatementProvided(provided_uuid, provided_statement_desc, test_href) + + statement.write_statement_md(statement_provided_path) + + # confirm content in yaml header + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(statement_provided_path) + assert tree is not None + + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'REPLACE_ME'}] + + assert header[const.TRESTLE_STATEMENT_TAG][const.PROVIDED_UUID] == provided_uuid + assert header[const.TRESTLE_GLOBAL_TAG][const.LEVERAGED_SSP][const.HREF] == test_href + + # Confirm markdown content + node = tree.get_node_for_key(const.PROVIDED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == '# Provided Statement Description\n\nprovided statement description' + + # Update the component mapping and run again to make sure it persists + add_authored_content(statement_provided_path, header) + + statement.write_statement_md(statement_provided_path) + + # Reread the markdown + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(statement_provided_path) + + # Ensure My_Comp persists + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'My_Comp'}] + node = tree.get_node_for_key(const.SATISFIED_STATEMENT_DESCRIPTION, False) + assert node is None + + +def test_write_inheritance_responsibility(tmp_path: pathlib.Path) -> None: + """Test writing statements with only responsibility.""" + statement_resp_path = tmp_path.joinpath('statement_req.md') + + statement = StatementResponsibility(resp_uuid, resp_statement_desc, test_href) + + statement.write_statement_md(statement_resp_path) + + # confirm content in yaml header + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(statement_resp_path) + assert tree is not None + + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'REPLACE_ME'}] + + assert header[const.TRESTLE_STATEMENT_TAG][const.RESPONSIBILITY_UUID] == resp_uuid + assert header[const.TRESTLE_GLOBAL_TAG][const.LEVERAGED_SSP][const.HREF] == test_href + + # Confirm markdown content + node = tree.get_node_for_key(const.RESPONSIBILITY_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == '# Responsibility Statement Description\n\nresp statement description\n' + node = tree.get_node_for_key(const.SATISFIED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == """# Satisfied Statement Description\n +""" + + # Update the component mapping and run again to make sure it persists + add_authored_content(statement_resp_path, header) + + statement.write_statement_md(statement_resp_path) + + # Reread the Markdown + md_api = MarkdownAPI() + header, tree = md_api.processor.process_markdown(statement_resp_path) + + # Ensure My_Comp and satisfied description persists + comp_header_value = header[const.TRESTLE_LEVERAGING_COMP_TAG] + assert comp_header_value == [{'name': 'My_Comp'}] + node = tree.get_node_for_key(const.SATISFIED_STATEMENT_DESCRIPTION, False) + assert node.content.raw_text == """# Satisfied Statement Description\n +\nMy Satisfied Description""" + + +def test_process_leveraged_statement_default_mapping(tmp_path: pathlib.Path) -> None: + """Test processing leveraged statement markdown with no set mapping.""" + statement_tree_path = tmp_path.joinpath('statement_tree.md') + + statement = StatementTree(provided_uuid, provided_statement_desc, resp_uuid, resp_statement_desc, test_href) + + statement.write_statement_md(statement_tree_path) + + md_reader: InheritanceMarkdownReader = InheritanceMarkdownReader(statement_tree_path) + + leveraging_information = md_reader.process_leveraged_statement_markdown() + + assert leveraging_information is None + + +def test_process_leveraged_statement_markdown_tree(tmp_path: pathlib.Path) -> None: + """Test processing a statement tree in Markdown.""" + statement_tree_path = tmp_path.joinpath('statement_tree.md') + + # Add test mapped component + test_header: Dict[str, Any] = {} + add_authored_content(statement_tree_path, test_header) + + statement = StatementTree(provided_uuid, provided_statement_desc, resp_uuid, resp_statement_desc, test_href) + + statement.write_statement_md(statement_tree_path) + + md_reader: InheritanceMarkdownReader = InheritanceMarkdownReader(statement_tree_path) + + leveraging_information = md_reader.process_leveraged_statement_markdown() + + assert leveraging_information + + assert 'My_Comp' in leveraging_information.leveraging_comp_titles + + assert leveraging_information.inherited is not None + inherited = leveraging_information.inherited + assert inherited.provided_uuid == provided_uuid + assert inherited.description == provided_statement_desc + + assert leveraging_information.satisfied is not None + satisfied = leveraging_information.satisfied + assert satisfied.responsibility_uuid == resp_uuid + assert satisfied.description == 'My Satisfied Description' + + +def test_process_leveraged_statement_markdown_provided(tmp_path: pathlib.Path) -> None: + """Test processing a statement provided markdown.""" + statement_provided_path = tmp_path.joinpath('statement_provided.md') + + # Add test mapped component + test_header: Dict[str, Any] = {} + add_authored_content(statement_provided_path, test_header) + + statement = StatementProvided(provided_uuid, provided_statement_desc, test_href) + + statement.write_statement_md(statement_provided_path) + + md_reader: InheritanceMarkdownReader = InheritanceMarkdownReader(statement_provided_path) + + leveraging_information = md_reader.process_leveraged_statement_markdown() + + assert leveraging_information + + assert 'My_Comp' in leveraging_information.leveraging_comp_titles + + assert leveraging_information.inherited is not None + inherited = leveraging_information.inherited + assert inherited.provided_uuid == provided_uuid + assert inherited.description == provided_statement_desc + + assert leveraging_information.satisfied is None + + +def test_process_leveraged_statement_markdown_responsibility(tmp_path: pathlib.Path) -> None: + """Test processing a statement responsibility Markdown.""" + statement_resp_path = tmp_path.joinpath('statement_req.md') + + # Add test mapped component + test_header: Dict[str, Any] = {} + add_authored_content(statement_resp_path, test_header) + + statement = StatementResponsibility(resp_uuid, resp_statement_desc, test_href) + + statement.write_statement_md(statement_resp_path) + + md_reader: InheritanceMarkdownReader = InheritanceMarkdownReader(statement_resp_path) + + leveraging_information = md_reader.process_leveraged_statement_markdown() + + assert leveraging_information + + assert 'My_Comp' in leveraging_information.leveraging_comp_titles + + assert leveraging_information.inherited is None + + assert leveraging_information.satisfied is not None + satisfied = leveraging_information.satisfied + assert satisfied.responsibility_uuid == resp_uuid + assert satisfied.description == 'My Satisfied Description' diff --git a/tests/trestle/core/crm/ssp_inheritance_api_test.py b/tests/trestle/core/crm/ssp_inheritance_api_test.py new file mode 100644 index 000000000..f4ebbc704 --- /dev/null +++ b/tests/trestle/core/crm/ssp_inheritance_api_test.py @@ -0,0 +1,174 @@ +# -*- mode:python; coding:utf-8 -*- +# Copyright (c) 2020 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for SSP Inheritance API.""" + +import copy +import logging +import pathlib + +from tests import test_utils + +import trestle.oscal.ssp as ossp +from trestle.common import const +from trestle.common.model_utils import ModelUtils +from trestle.core.crm.ssp_inheritance_api import SSPInheritanceAPI +from trestle.core.models.file_content_type import FileContentType + +logger = logging.getLogger(__name__) + +leveraging_ssp = 'my_ssp' +leveraged_ssp = 'leveraged_ssp' + +expected_application_uuid = '11111111-0000-4000-9001-000000000002' +example_provided_uuid = '18ac4e2a-b5f2-46e4-94fa-cc84ab6fe114' +example_responsibility_uuid = '4b34c68f-75fa-4b38-baf0-e50158c13ac2' + + +def prep_dir(component_dir: pathlib.Path) -> None: + """Prep dir.""" + ac_2 = component_dir.joinpath('ac-2') + ac_2.mkdir(parents=True) + + inheritance_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=['Access Control Appliance', 'THIS SYSTEM (SaaS)'], + leveraged_ssp_href='trestle://system-security-plans/leveraged_ssp/system-security-plan.json' + ) + + file = ac_2 / f'{expected_application_uuid}.md' + with open(file, 'w') as f: + f.write(inheritance_text) + + # test with a statement + ac_2a = component_dir.joinpath('ac-2_smt.a') + ac_2a.mkdir(parents=True) + + file = ac_2a / f'{expected_application_uuid}.md' + with open(file, 'w') as f: + f.write(inheritance_text) + + +def unmapped_prep_dir(component_dir: pathlib.Path) -> None: + """Unmapped prep dir.""" + ac_2 = component_dir.joinpath('ac-2') + ac_2.mkdir(parents=True) + + unmapped_text = test_utils.generate_test_inheritance_md( + provided_uuid=example_provided_uuid, + responsibility_uuid=example_responsibility_uuid, + leveraged_statement_names=[const.REPLACE_ME], + leveraged_ssp_href='trestle://system-security-plans/leveraged_ssp/system-security-plan.json' + ) + + file = ac_2 / f'{expected_application_uuid}.md' + with open(file, 'w') as f: + f.write(unmapped_text) + + # test with a statement + ac_2a = component_dir.joinpath('ac-2_smt.a') + ac_2a.mkdir(parents=True) + + file = ac_2a / f'{expected_application_uuid}.md' + with open(file, 'w') as f: + f.write(unmapped_text) + + +def test_update_ssp_inheritance(tmp_trestle_dir: pathlib.Path) -> None: + """Test that a leveraged authorization is created.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + application_dir = inheritance_path.joinpath('Application') + prep_dir(application_dir) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraged_ssp', leveraged_ssp, ossp.SystemSecurityPlan) + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + components = orig_ssp.system_implementation.components + + assert len(components) == 5 + assert len(orig_ssp.system_implementation.leveraged_authorizations) == 1 + + ssp_inheritance_api = SSPInheritanceAPI(inheritance_path, tmp_trestle_dir) + ssp_inheritance_api.update_ssp_inheritance(orig_ssp) + + assert orig_ssp.system_implementation.leveraged_authorizations is not None + + assert len(orig_ssp.system_implementation.leveraged_authorizations) == 1 + + auth = orig_ssp.system_implementation.leveraged_authorizations[0] + + assert auth.links is not None + assert len(auth.links) == 1 + assert auth.links[0].href == 'trestle://system-security-plans/leveraged_ssp/system-security-plan.json' + + components = orig_ssp.system_implementation.components + + # This reduce to 4 by removing old leveraged components and adding application + assert len(components) == 4 + + # Verify that all expected components are present + component_titles = [obj.title for obj in components] + + assert 'Access Control Appliance' in component_titles + assert 'THIS SYSTEM (SaaS)' in component_titles + assert 'Application' in component_titles + assert 'This System' in component_titles + + assert components[3].title == 'Application' + assert components[3].props is not None + assert len(components[3].props) == 3 + assert components[3].props[0].name == 'implementation-point' + assert components[3].props[0].value == 'external' + assert components[3].props[1].name == 'leveraged-authorization-uuid' + assert components[3].props[1].value == auth.uuid + assert components[3].props[2].name == 'inherited-uuid' + assert components[3].props[2].value == expected_application_uuid + + # Run twice and assert with no changes that the ssp is the same + copy_ssp = copy.deepcopy(orig_ssp) + ssp_inheritance_api.update_ssp_inheritance(orig_ssp) + assert ModelUtils.models_are_equivalent(orig_ssp, copy_ssp) # type: ignore + + +def test_no_leveraged_comps(tmp_trestle_dir: pathlib.Path) -> None: + """Test that a leveraged authorization is not created.""" + inheritance_path = tmp_trestle_dir.joinpath(leveraged_ssp, const.INHERITANCE_VIEW_DIR) + application_dir = inheritance_path.joinpath('Application') + unmapped_prep_dir(application_dir) + + test_utils.load_from_json(tmp_trestle_dir, 'leveraged_ssp', leveraged_ssp, ossp.SystemSecurityPlan) + test_utils.load_from_json(tmp_trestle_dir, 'leveraging_ssp', leveraging_ssp, ossp.SystemSecurityPlan) + + orig_ssp, _ = ModelUtils.load_model_for_class( + tmp_trestle_dir, + leveraging_ssp, + ossp.SystemSecurityPlan, + FileContentType.JSON) + + components = orig_ssp.system_implementation.components + + assert len(components) == 5 + assert len(orig_ssp.system_implementation.leveraged_authorizations) == 1 + + ssp_inheritance_api = SSPInheritanceAPI(inheritance_path, tmp_trestle_dir) + ssp_inheritance_api.update_ssp_inheritance(orig_ssp) + + assert orig_ssp.system_implementation.leveraged_authorizations is None diff --git a/trestle/common/const.py b/trestle/common/const.py index cb87530ca..74f0d6351 100644 --- a/trestle/common/const.py +++ b/trestle/common/const.py @@ -288,6 +288,8 @@ PROFILE = 'profile' +LEVERAGED_SSP = 'leveraged-ssp' + TITLE = 'title' NAME = 'name' @@ -350,6 +352,12 @@ LEV_AUTH_UUID = 'leveraged-authorization-uuid' +INHERITED_UUID = 'inherited-uuid' + +IMPLEMENTATION_POINT = 'implementation-point' + +IMPLEMENTATION_POINT_EXTERNAL = 'external' + STATUS_INHERITED = 'inherited' STATUS_PARTIALLY_IMPLEMENTED = 'partially-implemented' @@ -370,6 +378,10 @@ STATUS_REMARKS = 'status-remarks' +PROVIDED_STATEMENT_DESCRIPTION = 'Provided Statement Description' + +RESPONSIBILITY_STATEMENT_DESCRIPTION = 'Responsibility Statement Description' + # Following 5 are allowed state tokens for # SSP -> ControlImplementation -> ImplementedRequirements -> ByComponents -> common.ImplementationStatus -> State # Also -> ImplementedRequirements -> Statements -> ByComponents ... @@ -417,6 +429,11 @@ RULES_WARNING = '' # noqa E501 +SATISFIED_STATEMENT_COMMENT = ( + '' +) + THIS_SYSTEM_PROMPT = '### ' + SSP_MAIN_COMP_NAME RESPONSIBLE_ROLE = 'responsible-role' @@ -431,6 +448,10 @@ COMP_DEF_RULES_PARAM_VALS_TAG = TRESTLE_TAG + 'comp-def-rules-param-vals' +TRESTLE_LEVERAGING_COMP_TAG = TRESTLE_TAG + 'leveraging-comp' + +TRESTLE_STATEMENT_TAG = TRESTLE_TAG + 'statement' + PARAM_VALUES_TAG = TRESTLE_TAG + 'param-values' COMP_DEF_RULES_TAG = TRESTLE_TAG + 'comp-def-rules' @@ -465,6 +486,10 @@ REPLACE_ME = 'REPLACE_ME' +PROVIDED_UUID = 'provided-uuid' + +RESPONSIBILITY_UUID = 'responsibility-uuid' + YAML_PROPS_COMMENT = """ # Add or modify control properties here # Properties may be at the control or part level # Add control level properties like this: @@ -534,6 +559,13 @@ # """ +YAML_LEVERAGED_COMMENT = """ # Add or modify leveraged SSP Statements here. +""" + +YAML_LEVERAGING_COMP_COMMENT = """ # Leveraged statements can be optionally associated with components in this system. + # Associate leveraged statements to Components of this system here: +""" + DISPLAY_NAME = 'display-name' TRESTLE_GENERIC_NS = 'https://ibm.github.io/compliance-trestle/schemas/oscal' @@ -584,3 +616,11 @@ ORIGINATION_CUSTOMER_PROVIDED = 'customer-provided' ORIGINATION_INHERITED = 'inherited' + +# Constant relation to the inheritance view Markdown + +INHERITANCE_VIEW_DIR = 'inheritance' + +HELP_LEVERAGED = 'Name of the SSP to be leveraged.' + +SATISFIED_STATEMENT_DESCRIPTION = 'Satisfied Statement Description' diff --git a/trestle/core/catalog/catalog_reader.py b/trestle/core/catalog/catalog_reader.py index 423376175..f4d648afc 100644 --- a/trestle/core/catalog/catalog_reader.py +++ b/trestle/core/catalog/catalog_reader.py @@ -364,8 +364,18 @@ def read_ssp_md_content( """ for group_path in CatalogInterface._get_group_ids_and_dirs(md_path).values(): for control_file in group_path.glob('*.md'): + skip = False + for file in control_file.parents: + if file.name == const.INHERITANCE_VIEW_DIR: + skip = True + break + if skip: + continue + control_id = control_file.stem + md_header, control_comp_dict = CatalogReader._read_comp_info_from_md(control_file, context) + for comp_name, comp_info_dict in control_comp_dict.items(): if comp_name not in comp_dict: err_msg = f'Control {control_id} references component {comp_name} not defined in a component-definition.' # noqa E501 diff --git a/trestle/core/commands/author/ssp.py b/trestle/core/commands/author/ssp.py index 45bb30e64..b8868ee51 100644 --- a/trestle/core/commands/author/ssp.py +++ b/trestle/core/commands/author/ssp.py @@ -15,6 +15,7 @@ import argparse import logging +import os import pathlib from typing import Any, Dict, List, Optional, Set @@ -42,6 +43,7 @@ from trestle.core.control_context import ContextPurpose, ControlContext from trestle.core.control_interface import ControlInterface, ParameterRep from trestle.core.control_reader import ControlReader +from trestle.core.crm.ssp_inheritance_api import SSPInheritanceAPI from trestle.core.models.file_content_type import FileContentType from trestle.core.profile_resolver import ProfileResolver from trestle.core.remote.cache import FetcherFactory @@ -63,6 +65,10 @@ def _init_arguments(self) -> None: '-o', '--output', help='Name of the output generated ssp markdown folder', required=True, type=str ) # noqa E501 self.add_argument('-cd', '--compdefs', help=const.HELP_COMPDEFS, required=False, type=str) + + ls_help_str = 'Leveraged ssp with inheritable controls href or name in the trestle_workspace' + self.add_argument('-ls', '--leveraged-ssp', help=ls_help_str, required=False, type=str) + self.add_argument('-y', '--yaml-header', help=const.HELP_YAML_PATH, required=False, type=str) self.add_argument( '-fo', '--force-overwrite', help=const.HELP_FO_OUTPUT, required=False, action='store_true', default=False @@ -100,6 +106,7 @@ def _run(self, args: argparse.Namespace) -> int: trestle_root, args.profile, compdef_name_list, + args.leveraged_ssp, md_path, yaml_header, args.overwrite_header_values, @@ -114,6 +121,7 @@ def _generate_ssp_markdown( trestle_root: pathlib.Path, profile_name_or_href: str, compdef_name_list: List[str], + leveraged_ssp_name_or_href: str, md_path: pathlib.Path, yaml_header: Dict[str, Any], overwrite_header_values: bool, @@ -183,8 +191,44 @@ def _generate_ssp_markdown( catalog_api.write_catalog_as_markdown() + # Generate inheritance view after controls view completes + if leveraged_ssp_name_or_href: + self._generate_inheritance_markdown(trestle_root, leveraged_ssp_name_or_href, resolved_catalog, md_path) + return CmdReturnCodes.SUCCESS.value + def _generate_inheritance_markdown( + self, + trestle_root: pathlib.Path, + leveraged_ssp_name_or_href: str, + resolved_catalog: CatalogInterface, + md_path: str + ) -> None: + """ + Generate markdown for inheritance view. + + Notes: + This will create the inheritance view markdown files in the same directory as the ssp markdown files. + The information will be from the leveraged ssp, but filtered by the chosen profile to ensure only relevant + controls are present for mapping. + """ + # if file not recognized as URI form, assume it represents name of file in trestle directory + ssp_in_trestle_dir = '://' not in leveraged_ssp_name_or_href + ssp_href = leveraged_ssp_name_or_href + if ssp_in_trestle_dir: + local_path = f'{const.MODEL_DIR_SSP}/{leveraged_ssp_name_or_href}/system-security-plan.json' + ssp_href = const.TRESTLE_HREF_HEADING + local_path + + inheritance_view_path: pathlib.Path = md_path.joinpath(const.INHERITANCE_VIEW_DIR) + inheritance_view_path.mkdir(exist_ok=True) + logger.debug(f'Creating content for inheritance view in {inheritance_view_path}') + + ssp_inheritance_api = SSPInheritanceAPI(inheritance_view_path, trestle_root) + + # Filter the ssp implemented requirements by the catalog specified + catalog_api: CatalogAPI = CatalogAPI(catalog=resolved_catalog) + ssp_inheritance_api.write_inheritance_as_markdown(ssp_href, catalog_api) + class SSPAssemble(AuthorCommonCommand): """Assemble markdown files of controls into an SSP json file.""" @@ -519,10 +563,18 @@ def _run(self, args: argparse.Namespace) -> int: raise TrestleError('Original ssp has no system component.') comp_dict[const.SSP_MAIN_COMP_NAME] = sys_comp + ssp_sys_imp_comps = ssp.system_implementation.components + # Gather the leveraged components to add back after the merge + leveraged_comps: Dict[str, ossp.SystemComponent] = {} + for sys_comp in ssp_sys_imp_comps: + if sys_comp.props is not None: + prop_names = [x.name for x in sys_comp.props] + if const.LEV_AUTH_UUID in prop_names: + leveraged_comps[sys_comp.title] = sys_comp + # Verifies older compdefs in an ssp no longer exist in newly provided ones comp_titles = [x.title for x in comp_dict.values()] - ssp_sys_imp_comps = ssp.system_implementation.components - diffs = [x for x in ssp_sys_imp_comps if x.title not in comp_titles] + diffs = [x for x in ssp_sys_imp_comps if x.title not in comp_titles and x.title not in leveraged_comps] if diffs: for diff in diffs: logger.warning( @@ -537,6 +589,9 @@ def _run(self, args: argparse.Namespace) -> int: CatalogReader.read_ssp_md_content(md_path, ssp, comp_dict, part_id_map_by_label, context) new_file_content_type = FileContentType.path_to_content_type(orig_ssp_path) + + # Add the leveraged comps back to the final ssp + ssp.system_implementation.components.extend(list(leveraged_comps.values())) else: # create a sample ssp to hold all the parts ssp = gens.generate_sample_model(ossp.SystemSecurityPlan) @@ -554,6 +609,11 @@ def _run(self, args: argparse.Namespace) -> int: # TODO if the ssp already existed then components may need to be removed if not ref'd by imp_reqs self._generate_roles_in_metadata(ssp) + # If this is a leveraging SSP, update it with the retrieved exports from the leveraged SSP + inheritance_markdown_path = md_path.joinpath(const.INHERITANCE_VIEW_DIR) + if os.path.exists(inheritance_markdown_path): + SSPInheritanceAPI(inheritance_markdown_path, trestle_root).update_ssp_inheritance(ssp) + ssp.import_profile.href = profile_href if args.version: diff --git a/trestle/core/crm/__init__.py b/trestle/core/crm/__init__.py new file mode 100644 index 000000000..c67882572 --- /dev/null +++ b/trestle/core/crm/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Package of classes relating to leveraging and leveraged SSPs.""" diff --git a/trestle/core/crm/bycomp_interface.py b/trestle/core/crm/bycomp_interface.py new file mode 100644 index 000000000..d4fd885ec --- /dev/null +++ b/trestle/core/crm/bycomp_interface.py @@ -0,0 +1,172 @@ +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Provide interface to by-component allowing queries and operations for exports/inheritance statements.""" + +import copy +import logging +from typing import Dict, List, Tuple + +import trestle.oscal.ssp as ossp +from trestle.common.err import TrestleError +from trestle.common.list_utils import as_dict, as_list, none_if_empty + +logger = logging.getLogger(__name__) + + +class ByComponentInterface: + """ + Interface to query and modify by-component assembly inheritance contents. + + The by-component is contained in two separate forms: As an actual OSCAL by-component assembly, + and multiple dicts providing direct lookup of inheritance statement by uuid. + + The dicts are created by the ByComponentInterface constructor, parsed and the responsibility and provided statements + are separated into three catagories: + + isolated responsibilities - A responsibility with no provided statement + isolated provided - A provided statement with no referring responsibility statements + export set - A set with a single responsibility and referred provided statement + + For updating ByComponent inheritance and satisfied statements, the interface provides a method to reconcile the + the by-component assembly and merge input inherited and satisfied statements. + """ + + def __init__(self, by_comp: ossp.ByComponent): + """Initialize export writer for a single by-component assembly.""" + self._by_comp: ossp.ByComponent = by_comp + + self._provided_dict: Dict[str, ossp.Provided] = {} + self._responsibility_dict: Dict[str, ossp.Responsibility] = {} + self._responsibility_by_provided: Dict[str, List[ossp.Responsibility]] = {} + + self._inherited_dict: Dict[str, ossp.Inherited] = self._create_inherited_dict() + self._satisfied_dict: Dict[str, ossp.Satisfied] = self._create_satisfied_dict() + + if by_comp.export: + self._provided_dict = self._create_provided_dict() + self._responsibility_dict = self._create_responsibility_dict() + self._responsibility_by_provided = self._create_responsibility_by_provided_dict() + + def _create_provided_dict(self) -> Dict[str, ossp.Provided]: + provided_dict: Dict[str, ossp.Provided] = {} + for provided in as_list(self._by_comp.export.provided): + provided_dict[provided.uuid] = provided + return provided_dict + + def _create_responsibility_dict(self) -> Dict[str, ossp.Responsibility]: + responsibility_dict: Dict[str, ossp.Responsibility] = {} + for responsibility in as_list(self._by_comp.export.responsibilities): + responsibility_dict[responsibility.uuid] = responsibility + return responsibility_dict + + def _create_responsibility_by_provided_dict(self) -> Dict[str, List[ossp.Responsibility]]: + responsibility_by_provided: Dict[str, List[ossp.Responsibility]] = {} + for responsibility in as_list(self._by_comp.export.responsibilities): + if responsibility.provided_uuid is None: + continue + if responsibility.provided_uuid not in responsibility_by_provided: + responsibility_by_provided[responsibility.provided_uuid] = [responsibility] + else: + existing_list: List[ossp.Responsibility] = responsibility_by_provided[responsibility.provided_uuid] + existing_list.append(responsibility) + return responsibility_by_provided + + def _create_inherited_dict(self) -> Dict[str, ossp.Inherited]: + inherited_dict: Dict[str, ossp.Inherited] = {} + for inherited in as_list(self._by_comp.inherited): + inherited_dict[str(inherited.provided_uuid)] = inherited + return inherited_dict + + def _create_satisfied_dict(self) -> Dict[str, ossp.Satisfied]: + satisfied_dict: Dict[str, ossp.Satisfied] = {} + for satisfied in as_list(self._by_comp.satisfied): + satisfied_dict[str(satisfied.responsibility_uuid)] = satisfied + return satisfied_dict + + def get_isolated_responsibilities(self) -> List[ossp.Responsibility]: + """Return all isolated exported responsibilities.""" + all_responsibilities: List[ossp.Responsibility] = [] + for resp in as_dict(self._responsibility_dict).values(): + if resp.provided_uuid is None: + all_responsibilities.append(resp) + return all_responsibilities + + def get_isolated_provided(self) -> List[ossp.Provided]: + """Return all isolated exported provided capabilities.""" + all_provided: List[ossp.Provided] = [] + for provided in as_dict(self._provided_dict).values(): + if not self._provided_has_responsibilities(provided.uuid): + all_provided.append(provided) + return all_provided + + def get_export_sets(self) -> List[Tuple[ossp.Responsibility, ossp.Provided]]: + """Return a dictionary of every responsibility relationship with provided.""" + all_export_sets: List[Tuple[ossp.Responsibility, ossp.Provided]] = [] + for provided_uuid, responsibilities in as_dict(self._responsibility_by_provided).items(): + + # Ensure the provided object exists in the dictionary. + # If it doesn't this is a bug. + if provided_uuid not in self._provided_dict: + raise TrestleError(f'Provided capability {provided_uuid} not found') + provided: ossp.Provided = self._provided_dict[provided_uuid] + + for responsibility in responsibilities: + shared_responsibility: Tuple[ossp.Responsibility, ossp.Provided] = (responsibility, provided) + all_export_sets.append(shared_responsibility) + return all_export_sets + + def reconcile_inheritance_by_component( + self, incoming_inherited: List[ossp.Inherited], incoming_satisfied: List[ossp.Satisfied] + ) -> ossp.ByComponent: + """ + Reconcile the inherited and satisfied statements in the by-component assembly with changes from the export. + + Notes: + A statement is determined as existing if the provided uuid or responsibility uuid is in the existing + by-component assembly. If existing, the description will be updated if it has changed. + + Any existing inherited or satisfied statements that are not in the incoming export will be removed. + If a statement is in the incoming export, but not in the existing by-component assembly, it will be added. + """ + new_inherited: List[ossp.Inherited] = [] + new_satisfied: List[ossp.Satisfied] = [] + + # Create a copy of the input by-component assembly to reconcile and return + new_by_comp: ossp.ByComponent = copy.deepcopy(self._by_comp) + + for statement in incoming_inherited: + if statement.provided_uuid in self._inherited_dict: + existing_statement = self._inherited_dict[str(statement.provided_uuid)] + # Update the description if it has changed + existing_statement.description = statement.description + statement = existing_statement + new_inherited.append(statement) + + new_by_comp.inherited = none_if_empty(new_inherited) + + for statement in incoming_satisfied: + if statement.responsibility_uuid in self._satisfied_dict: + existing_statement = self._satisfied_dict[str(statement.responsibility_uuid)] + # Update the description if it has changed + existing_statement.description = statement.description + statement = existing_statement + new_satisfied.append(statement) + + new_by_comp.satisfied = none_if_empty(new_satisfied) + + return new_by_comp + + def _provided_has_responsibilities(self, provided_uuid: str) -> bool: + """Return whether a provided UUID has responsibilities.""" + return provided_uuid in self._responsibility_by_provided diff --git a/trestle/core/crm/export_reader.py b/trestle/core/crm/export_reader.py new file mode 100644 index 000000000..7f831de07 --- /dev/null +++ b/trestle/core/crm/export_reader.py @@ -0,0 +1,267 @@ +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Provided interface to read inheritance statements from Markdown.""" + +import logging +import os +import pathlib +from typing import Dict, List, Tuple + +import trestle.core.generators as gens +import trestle.oscal.ssp as ossp +from trestle.common.common_types import TypeWithByComps +from trestle.common.err import TrestleError +from trestle.common.list_utils import as_list, none_if_empty +from trestle.core.crm.bycomp_interface import ByComponentInterface +from trestle.core.crm.leveraged_statements import InheritanceMarkdownReader + +logger = logging.getLogger(__name__) + +# Provide name for this type +# Containing dictionary that is keyed by by_component uuid with a tuple of inherited and satisfied statements +ByComponentDict = Dict[str, Tuple[List[ossp.Inherited], List[ossp.Satisfied]]] + +# Provide name for this type +# Containing dictionary that is keyed by control id with a dictionary of by_component information +InheritanceViewDict = Dict[str, ByComponentDict] + + +class ExportReader: + """ + By-Component Assembly Exports Markdown reader. + + Export reader handles all operations related to reading authored inherited and satisfied statements from exports + in Markdown. The reader will read all the markdown files in the exports directory and update the SSP with the + inheritance. + """ + + def __init__(self, root_path: pathlib.Path, ssp: ossp.SystemSecurityPlan): + """ + Initialize export reader. + + Arguments: + root_path: A root path object where an SSP's inheritance markdown is located. + ssp: A system security plan object that will be updated with the inheritance information. + + Notes: + The mapped components list is used to track which components have been mapped to controls in the markdown. + It can be retrieved with the get_leveraged_components method. This will be empty until the + read_exports_from_markdown method is called. + """ + self._ssp: ossp.SystemSecurityPlan = ssp + + # Create a dictionary of implemented requirements keyed by control id for merging operations + self._implemented_requirements: Dict[str, ossp.ImplementedRequirement] = self._create_impl_req_dict() + + # List of component titles that have been mapped to controls in the Markdown + self._mapped_components: List[str] = [] + + self._root_path: pathlib.Path = root_path + + def _create_impl_req_dict(self) -> Dict[str, ossp.ImplementedRequirement]: + """Create a dictionary of implemented requirements keyed by control id.""" + impl_req_dict: Dict[str, ossp.ImplementedRequirement] = {} + for impl_req in as_list(self._ssp.control_implementation.implemented_requirements): + impl_req_dict[impl_req.control_id] = impl_req + return impl_req_dict + + def read_exports_from_markdown(self) -> ossp.SystemSecurityPlan: + """Read inheritance markdown and update the SSP with the inheritance information.""" + # Read the information from the markdown files into a dictionary for quick lookup + markdown_dict: InheritanceViewDict = self._read_inheritance_markdown_directory() + + # Merge the markdown information into existing the implemented requirements + self._merge_exports_implemented_requirements(markdown_dict) + + # Process remaining markdown information that was not in the implemented requirements + for control_id, by_comp_dict in markdown_dict.items(): + if by_comp_dict: + logging.debug(f'Adding control mapping {control_id} to implemented requirements') + self._add_control_mappings_to_implemented_requirements(control_id, by_comp_dict) + + self._ssp.control_implementation.implemented_requirements = list(self._implemented_requirements.values()) + return self._ssp + + def get_leveraged_ssp_href(self) -> str: + """Get the href of the leveraged SSP from a markdown file.""" + comp_dirs = os.listdir(self._root_path) + if len(comp_dirs) == 0: + raise TrestleError('No components were found in the markdown.') + + control_dirs = os.listdir(self._root_path.joinpath(comp_dirs[0])) + if len(control_dirs) == 0: + raise TrestleError('No controls were found in the markdown for component {comp_dirs[0]}.') + + control_dir = self._root_path.joinpath(comp_dirs[0], control_dirs[0]) + + files = [f for f in os.listdir(control_dir) if os.path.isfile(os.path.join(control_dir, f))] + if len(files) == 0: + raise TrestleError(f'No files were found in the markdown for control {control_dirs[0]}.') + + markdown_file_path = control_dir.joinpath(files[0]) + reader = InheritanceMarkdownReader(markdown_file_path) + return reader.get_leveraged_ssp_reference() + + def get_leveraged_components(self) -> List[str]: + """Get a list of component titles that have been mapped to controls in the Markdown.""" + return self._mapped_components + + def _merge_exports_implemented_requirements(self, markdown_dict: InheritanceViewDict) -> None: + """Merge all exported inheritance info from the markdown into the implemented requirement dict.""" + for implemented_requirement in self._implemented_requirements.values(): + + # If the control id existing in the markdown, then update the by_components + if implemented_requirement.control_id in markdown_dict: + + # Delete the entry from the markdown_dict once processed to avoid duplicates + by_comp_dict: ByComponentDict = markdown_dict.pop(implemented_requirement.control_id) + + self._update_type_with_by_comp(implemented_requirement, by_comp_dict) + + # Update any implemented requirements statements assemblies + new_statements: List[ossp.Statement] = [] + + for stm in as_list(implemented_requirement.statements): + statement_id = getattr(stm, 'statement_id', f'{implemented_requirement.control_id}_smt') + + # If the statement id existing in the markdown, then update the by_components + if statement_id in markdown_dict: + + # Delete the entry from the markdown_dict once processed to avoid duplicates + by_comp_dict: ByComponentDict = markdown_dict.pop(statement_id) + + self._update_type_with_by_comp(stm, by_comp_dict) + + new_statements.append(stm) + + implemented_requirement.statements = none_if_empty(new_statements) + + def _update_type_with_by_comp(self, with_bycomp: TypeWithByComps, by_comp_dict: ByComponentDict) -> None: + """Update the by_components for a type with by_components.""" + new_by_comp: List[ossp.ByComponent] = [] + + by_comp: ossp.ByComponent + comp_inheritance_info: Tuple[List[ossp.Inherited], List[ossp.Satisfied]] + for by_comp in as_list(with_bycomp.by_components): + + # If the by_component uuid exists in the by_comp_dict, then update it + # If not, clear the by_component inheritance information + comp_inheritance_info = by_comp_dict.pop(by_comp.component_uuid, ([], [])) + + bycomp_interface = ByComponentInterface(by_comp) + by_comp = bycomp_interface.reconcile_inheritance_by_component( + comp_inheritance_info[0], comp_inheritance_info[1] + ) + + new_by_comp.append(by_comp) + + # Add any new by_components that were not in the original statement + new_by_comp.extend(ExportReader._add_new_by_comps(by_comp_dict)) + with_bycomp.by_components = none_if_empty(new_by_comp) + + def _add_control_mappings_to_implemented_requirements( + self, control_mapping: str, by_comps: ByComponentDict + ) -> None: + """Add control mappings to implemented requirements.""" + # Determine if the control id is actually a statement id + if '_smt.' in control_mapping: + control_id = control_mapping.split('_smt')[0] + implemented_req = self._add_or_get_implemented_requirement(control_id) + statement = gens.generate_sample_model(ossp.Statement) + statement.statement_id = control_mapping + statement.by_components = ExportReader._add_new_by_comps(by_comps) + implemented_req.statements = as_list(implemented_req.statements) + implemented_req.statements.append(statement) + implemented_req.statements = sorted(implemented_req.statements, key=lambda x: x.statement_id) + else: + implemented_req = self._add_or_get_implemented_requirement(control_mapping) + implemented_req.by_components = as_list(implemented_req.by_components) + implemented_req.by_components.extend(ExportReader._add_new_by_comps(by_comps)) + + def _add_or_get_implemented_requirement(self, control_id: str) -> ossp.ImplementedRequirement: + """Add or get implemented requirement from implemented requirements dictionary.""" + if control_id in self._implemented_requirements: + return self._implemented_requirements[control_id] + + implemented_requirement = gens.generate_sample_model(ossp.ImplementedRequirement) + implemented_requirement.control_id = control_id + self._implemented_requirements[control_id] = implemented_requirement + return implemented_requirement + + @staticmethod + def _add_new_by_comps(by_comp_dict: ByComponentDict) -> List[ossp.ByComponent]: + """Add new by_components to the implemented requirement.""" + new_by_comp: List[ossp.ByComponent] = [] + for comp_uuid, inheritance_info in by_comp_dict.items(): + by_comp: ossp.ByComponent = gens.generate_sample_model(ossp.ByComponent) + by_comp.component_uuid = comp_uuid + by_comp.inherited = none_if_empty(inheritance_info[0]) + by_comp.satisfied = none_if_empty(inheritance_info[1]) + new_by_comp.append(by_comp) + return new_by_comp + + def _read_inheritance_markdown_directory(self) -> InheritanceViewDict: + """Read all inheritance markdown files and return a dictionary of all the information.""" + markdown_dict: InheritanceViewDict = {} + + # Creating a dictionary to find the component uuid by title for faster lookup + uuid_by_title: Dict[str, str] = {} + for component in as_list(self._ssp.system_implementation.components): + uuid_by_title[component.title] = component.uuid + + for comp_dir in os.listdir(self._root_path): + is_comp_leveraged = False + for control_dir in os.listdir(os.path.join(self._root_path, comp_dir)): + control_dir_path = os.path.join(self._root_path, comp_dir, control_dir) + + # Initialize the by_component dictionary for the control directory + # If it exists in the markdown dictionary, then update it with the new information + by_comp_dict = markdown_dict.get(control_dir, {}) + + for file in os.listdir(control_dir_path): + file_path = pathlib.Path(control_dir_path).joinpath(file) + reader = InheritanceMarkdownReader(file_path) + leveraged_info = reader.process_leveraged_statement_markdown() + + # If there is no leveraged information, then skip this file + if leveraged_info is None: + continue + + # If a file has leveraged information, then set the flag to indicate the component is leveraged + is_comp_leveraged = True + + for comp in leveraged_info.leveraging_comp_titles: + if comp not in uuid_by_title: + keys_as_string = ', '.join(uuid_by_title.keys()) + raise TrestleError( + f'Component "{comp}" does not exist in the {self._ssp.metadata.title} SSP. ' + f'Please use options: {keys_as_string}.' + ) + + comp_uuid = uuid_by_title[comp] + inherited, satisfied = by_comp_dict.get(comp_uuid, ([], [])) + + if leveraged_info.inherited is not None: + inherited.append(leveraged_info.inherited) + if leveraged_info.satisfied is not None: + satisfied.append(leveraged_info.satisfied) + + by_comp_dict[comp_uuid] = (inherited, satisfied) + + markdown_dict[control_dir] = by_comp_dict + + if is_comp_leveraged: + self._mapped_components.append(comp_dir) + + return markdown_dict diff --git a/trestle/core/crm/export_writer.py b/trestle/core/crm/export_writer.py new file mode 100644 index 000000000..83c81f91d --- /dev/null +++ b/trestle/core/crm/export_writer.py @@ -0,0 +1,117 @@ +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Provided interface to write provided and responsibility exports statements to Markdown.""" + +import logging +import pathlib +from typing import Dict + +import trestle.common.const as const +import trestle.oscal.ssp as ossp +from trestle.common.err import TrestleError +from trestle.common.list_utils import as_list +from trestle.core.crm.bycomp_interface import ByComponentInterface +from trestle.core.crm.leveraged_statements import ( + LeveragedStatements, + StatementProvided, + StatementResponsibility, + StatementTree, +) + +logger = logging.getLogger(__name__) + + +class ExportWriter: + """ + By-Component Assembly Exports writer. + + Export writer handles all operations related to writing provided and responsibility exported statements + to Markdown. + """ + + def __init__(self, root_path: pathlib.Path, ssp: ossp.SystemSecurityPlan, leveraged_ssp_href: str): + """ + Initialize export writer. + + Arguments: + root_path: A root path object where all markdown files and directories should be written. + ssp: A system security plan with exports + """ + self._ssp: ossp.SystemSecurityPlan = ssp + self._root_path: pathlib.Path = root_path + self._leveraged_ssp_href: str = leveraged_ssp_href + + # Find all the components and create paths for name + self._paths_by_comp: Dict[str, pathlib.Path] = {} + for component in as_list(self._ssp.system_implementation.components): + self._paths_by_comp[component.uuid] = self._root_path.joinpath(component.title) + + def write_exports_as_markdown(self) -> None: + """Write export statement for leveraged SSP as the inheritance Markdown view.""" + # Process all information under exports in control implementation + for implemented_requirement in as_list(self._ssp.control_implementation.implemented_requirements): + for by_comp in as_list(implemented_requirement.by_components): + self._process_by_component(by_comp, implemented_requirement.control_id) + + for stm in as_list(implemented_requirement.statements): + statement_id = getattr(stm, 'statement_id', f'{implemented_requirement.control_id}_smt') + for by_comp in as_list(stm.by_components): + self._process_by_component(by_comp, statement_id) + + def _process_by_component(self, by_comp: ossp.ByComponent, control_id: str) -> None: + """Complete the Markdown writing operations for each by-component assembly.""" + if by_comp.component_uuid not in self._paths_by_comp: + raise TrestleError(f'Component id {by_comp.component_uuid} is not in the system implementation') + + comp_path: pathlib.Path = self._paths_by_comp[by_comp.component_uuid] + + export_interface: ByComponentInterface = ByComponentInterface(by_comp=by_comp) + leveraged_statements: Dict[str, LeveragedStatements] = self._statement_types_from_exports(export_interface) + + # Only create the directory if leveraged statements exist. If not return. + if not leveraged_statements: + logger.debug(f'Component {by_comp.component_uuid} has no exports for control {control_id}') + return + + control_path: pathlib.Path = comp_path.joinpath(control_id) + control_path.mkdir(exist_ok=True, parents=True) + + for statement_file_path, leveraged_stm in leveraged_statements.items(): + statement_path: pathlib.Path = control_path.joinpath(f'{statement_file_path}{const.MARKDOWN_FILE_EXT}') + leveraged_stm.write_statement_md(statement_path) + + def _statement_types_from_exports(self, export_interface: ByComponentInterface) -> Dict[str, LeveragedStatements]: + """Process all exports and return a file basename and LeveragedStatement object for each.""" + all_statements: Dict[str, LeveragedStatements] = {} + + for responsibility in export_interface.get_isolated_responsibilities(): + all_statements[responsibility.uuid] = StatementResponsibility( + responsibility.uuid, responsibility.description, self._leveraged_ssp_href + ) + + for provided in export_interface.get_isolated_provided(): + all_statements[provided.uuid + ] = StatementProvided(provided.uuid, provided.description, self._leveraged_ssp_href) + + for responsibility, provided in export_interface.get_export_sets(): + path = f'{provided.uuid}_{responsibility.uuid}' + all_statements[path] = StatementTree( + provided.uuid, + provided.description, + responsibility.uuid, + responsibility.description, + self._leveraged_ssp_href + ) + + return all_statements diff --git a/trestle/core/crm/leveraged_statements.py b/trestle/core/crm/leveraged_statements.py new file mode 100644 index 000000000..a842960d1 --- /dev/null +++ b/trestle/core/crm/leveraged_statements.py @@ -0,0 +1,335 @@ +# Copyright (c) 2022 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Handle writing of inherited statements to markdown.""" +import logging +import pathlib +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import trestle.common.const as const +import trestle.core.generators as gens +import trestle.oscal.ssp as ssp +from trestle.common.err import TrestleError +from trestle.core.markdown.docs_markdown_node import DocsMarkdownNode +from trestle.core.markdown.markdown_api import MarkdownAPI +from trestle.core.markdown.md_writer import MDWriter + +logger = logging.getLogger(__name__) + +component_mapping_default: List[Dict[str, str]] = [{'name': const.REPLACE_ME}] + + +class LeveragedStatements(ABC): + """Abstract class for managing leveraged statements.""" + + def __init__(self, leveraged_ssp_reference: str) -> None: + """Initialize the class.""" + self._md_file: Optional[MDWriter] = None + self.header_comment_dict: Dict[str, str] = { + const.TRESTLE_LEVERAGING_COMP_TAG: const.YAML_LEVERAGING_COMP_COMMENT, + const.TRESTLE_STATEMENT_TAG: const.YAML_LEVERAGED_COMMENT + } + self.merged_header_dict: Dict[str, Any] = { + const.TRESTLE_STATEMENT_TAG: '', + const.TRESTLE_LEVERAGING_COMP_TAG: component_mapping_default, + const.TRESTLE_GLOBAL_TAG: { + const.LEVERAGED_SSP: { + const.HREF: leveraged_ssp_reference + } + } + } + + @abstractmethod + def write_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Write inheritance information to a single markdown file.""" + + +class StatementTree(LeveragedStatements): + """Concrete class for managing provided and responsibility statements.""" + + def __init__( + self, + provided_uuid: str, + provided_description: str, + responsibility_uuid: str, + responsibility_description: str, + leveraged_ssp_reference: str + ) -> None: + """Initialize the class.""" + self.provided_uuid = provided_uuid + self.provided_description = provided_description + self.responsibility_uuid = responsibility_uuid + self.responsibility_description = responsibility_description + self.satisfied_description = '' + super().__init__(leveraged_ssp_reference) + + def write_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Write a provided and responsibility statements to a markdown file.""" + self._md_file = MDWriter(leveraged_statement_file, self.header_comment_dict) + + if self._md_file.exists(): + return self.update_statement_md(leveraged_statement_file) + + self._add_generated_content() + self._md_file.write_out() + + def update_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Update provided and responsibility statements in a markdown file.""" + md_reader = InheritanceMarkdownReader(leveraged_statement_file) + + self.merged_header_dict[const.TRESTLE_LEVERAGING_COMP_TAG] = md_reader.get_leveraged_component_header_value() + + satisfied_description = md_reader.get_satisfied_description() + if satisfied_description is not None: + self.satisfied_description = satisfied_description + + self._add_generated_content() + self._md_file.write_out() + + def _add_generated_content(self) -> None: + statement_dict: Dict[str, str] = { + const.PROVIDED_UUID: self.provided_uuid, const.RESPONSIBILITY_UUID: self.responsibility_uuid + } + + self.merged_header_dict[const.TRESTLE_STATEMENT_TAG] = statement_dict + self._md_file.add_yaml_header(self.merged_header_dict) + + self._md_file.new_header(level=1, title=const.PROVIDED_STATEMENT_DESCRIPTION) + self._md_file.new_line(self.provided_description) + self._md_file.new_header(level=1, title=const.RESPONSIBILITY_STATEMENT_DESCRIPTION) + self._md_file.new_line(self.responsibility_description) + self._md_file.new_header(level=1, title=const.SATISFIED_STATEMENT_DESCRIPTION) + self._md_file.new_line(const.SATISFIED_STATEMENT_COMMENT) + self._md_file.new_line(self.satisfied_description) + + +class StatementProvided(LeveragedStatements): + """Concrete class for managing provided statements.""" + + def __init__( + self, + provided_uuid: str, + provided_description: str, + leveraged_ssp_reference: str, + ) -> None: + """Initialize the class.""" + self.provided_uuid = provided_uuid + self.provided_description = provided_description + super().__init__(leveraged_ssp_reference) + + def write_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Write provided statements to a markdown file.""" + self._md_file = MDWriter(leveraged_statement_file, self.header_comment_dict) + + if self._md_file.exists(): + return self.update_statement_md(leveraged_statement_file) + + self._add_generated_content() + self._md_file.write_out() + + def update_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Update provided statements in a markdown file.""" + md_reader = InheritanceMarkdownReader(leveraged_statement_file) + + self.merged_header_dict[const.TRESTLE_LEVERAGING_COMP_TAG] = md_reader.get_leveraged_component_header_value() + + self._add_generated_content() + self._md_file.write_out() + + def _add_generated_content(self) -> None: + self.merged_header_dict[const.TRESTLE_STATEMENT_TAG] = {const.PROVIDED_UUID: self.provided_uuid} + self._md_file.add_yaml_header(self.merged_header_dict) + + self._md_file.new_header(level=1, title=const.PROVIDED_STATEMENT_DESCRIPTION) + self._md_file.new_line(self.provided_description) + + +class StatementResponsibility(LeveragedStatements): + """Concrete class for managing responsibility statements.""" + + def __init__( + self, + responsibility_uuid: str, + responsibility_description: str, + leveraged_ssp_reference: str, + ) -> None: + """Initialize the class.""" + self.responsibility_uuid = responsibility_uuid + self.responsibility_description = responsibility_description + self.satisfied_description = '' + super().__init__(leveraged_ssp_reference) + + def write_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Write responsibility statements to a markdown file.""" + self._md_file = MDWriter(leveraged_statement_file, self.header_comment_dict) + + if self._md_file.exists(): + return self.update_statement_md(leveraged_statement_file) + + self._add_generated_content() + self._md_file.write_out() + + def update_statement_md(self, leveraged_statement_file: pathlib.Path) -> None: + """Update responsibility statements in a markdown file.""" + md_reader = InheritanceMarkdownReader(leveraged_statement_file) + + self.merged_header_dict[const.TRESTLE_LEVERAGING_COMP_TAG] = md_reader.get_leveraged_component_header_value() + + satisfied_description = md_reader.get_satisfied_description() + if satisfied_description is not None: + self.satisfied_description = satisfied_description + + self._add_generated_content() + self._md_file.write_out() + + def _add_generated_content(self) -> None: + self.merged_header_dict[const.TRESTLE_STATEMENT_TAG] = {const.RESPONSIBILITY_UUID: self.responsibility_uuid} + self._md_file.add_yaml_header(self.merged_header_dict) + + self._md_file.new_header(level=1, title=const.RESPONSIBILITY_STATEMENT_DESCRIPTION) + self._md_file.new_line(self.responsibility_description) + self._md_file.new_header(level=1, title=const.SATISFIED_STATEMENT_DESCRIPTION) + self._md_file.new_line(const.SATISFIED_STATEMENT_COMMENT) + self._md_file.new_line(self.satisfied_description) + + +@dataclass +class InheritanceInfo: + """Class to capture component inheritance information.""" + + leveraging_comp_titles: List[str] + inherited: Optional[ssp.Inherited] + satisfied: Optional[ssp.Satisfied] + + +class InheritanceMarkdownReader: + """Class to read leveraged statement information from Markdown.""" + + def __init__(self, leveraged_statement_file: pathlib.Path) -> None: + """Initialize the class.""" + # Save the file name for logging + self._leveraged_statement_file = leveraged_statement_file + + md_api: MarkdownAPI = MarkdownAPI() + + yaml_header, inheritance_md = md_api.processor.process_markdown(leveraged_statement_file) + self._yaml_header: Dict[str, Any] = yaml_header + self._inheritance_md: DocsMarkdownNode = inheritance_md + + def process_leveraged_statement_markdown(self) -> Optional[InheritanceInfo]: + """ + Read inheritance information from Markdown. + + Returns: + Optional InheritanceInfo - A list of mapped component titles, an optional satisfied statement and an + optional inherited statement + + Notes: + Returns inheritance information in the context of the leveraging SSP. If no leveraging component titles are + mapped in the yaml header None will be returned. The satisfied and inherited fields are + generated and returned to added information to by-component assemblies for + the mapped leveraging components. + + """ + leveraging_comps: List[str] = [] + inherited_statement: Optional[ssp.Inherited] = None + satisfied_statement: Optional[ssp.Satisfied] = None + + leveraging_comp_header_value: List[Dict[str, str]] = self._yaml_header[const.TRESTLE_LEVERAGING_COMP_TAG] + + # If there are no mapped components, return early + if not leveraging_comp_header_value or leveraging_comp_header_value == component_mapping_default: + return None + else: + for comp_dicts in leveraging_comp_header_value: + for comp_title in comp_dicts.values(): + leveraging_comps.append(comp_title) + + statement_info: Dict[str, str] = self._yaml_header[const.TRESTLE_STATEMENT_TAG] + + if const.PROVIDED_UUID in statement_info: + # Set inherited + + provided_description = self.get_provided_description() + if provided_description is None: + raise TrestleError(f'Provided statement cannot be empty in {self._leveraged_statement_file}') + + inherited_statement = gens.generate_sample_model(ssp.Inherited) + + inherited_statement.description = provided_description + inherited_statement.provided_uuid = statement_info[const.PROVIDED_UUID] + + if const.RESPONSIBILITY_UUID in statement_info: + # Set satisfied + satisfied_description = self.get_satisfied_description() + if satisfied_description is None: + raise TrestleError(f'Satisfied statement cannot be empty in {self._leveraged_statement_file}') + + satisfied_statement = gens.generate_sample_model(ssp.Satisfied) + + satisfied_statement.description = satisfied_description + satisfied_statement.responsibility_uuid = statement_info[const.RESPONSIBILITY_UUID] + + return InheritanceInfo(leveraging_comps, inherited_statement, satisfied_statement) + + def get_leveraged_ssp_reference(self) -> str: + """Return the leveraged SSP reference in the yaml header.""" + return self._yaml_header[const.TRESTLE_GLOBAL_TAG][const.LEVERAGED_SSP][const.HREF] + + def get_satisfied_description(self) -> Optional[str]: + """Return the satisfied description in the Markdown.""" + node = self._inheritance_md.get_node_for_key(const.SATISFIED_STATEMENT_DESCRIPTION, False) + if node is not None: + return self.strip_heading_and_comments(node.content.raw_text) + else: + return None + + def get_provided_description(self) -> Optional[str]: + """Return the provided description in the Markdown.""" + node = self._inheritance_md.get_node_for_key(const.PROVIDED_STATEMENT_DESCRIPTION, False) + if node is not None: + return self.strip_heading_and_comments(node.content.raw_text) + else: + return None + + def get_leveraged_component_header_value(self) -> Dict[str, str]: + """Provide the leveraged component value in the yaml header.""" + return self._yaml_header[const.TRESTLE_LEVERAGING_COMP_TAG] + + @staticmethod + def strip_heading_and_comments(markdown_text: str) -> str: + """Remove the heading and comments from lines to get the multi-line paragraph.""" + lines = markdown_text.split('\n') + non_heading_comment_lines = [] + inside_heading = False + inside_comment = False + + for line in lines: + if line.startswith('#'): + inside_heading = True + elif line.startswith(''): + inside_comment = False + continue + elif line.strip() == '': + inside_heading = False + inside_comment = False + + if not inside_heading and not inside_comment: + non_heading_comment_lines.append(line) + + stripped_markdown = '\n'.join(non_heading_comment_lines).strip() + return stripped_markdown diff --git a/trestle/core/crm/ssp_inheritance_api.py b/trestle/core/crm/ssp_inheritance_api.py new file mode 100644 index 000000000..3f3c2dd40 --- /dev/null +++ b/trestle/core/crm/ssp_inheritance_api.py @@ -0,0 +1,191 @@ +# -*- mode:python; coding:utf-8 -*- +# Copyright (c) 2020 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API for updating inheritance information in SSPs.""" + +import logging +import pathlib +from typing import Dict, List, Optional + +import trestle.core.generators as gens +import trestle.oscal.common as common +import trestle.oscal.ssp as ossp +from trestle.common import const +from trestle.common.err import TrestleError +from trestle.common.list_utils import as_list, none_if_empty +from trestle.core.catalog.catalog_api import CatalogAPI +from trestle.core.crm.export_reader import ExportReader +from trestle.core.crm.export_writer import ExportWriter +from trestle.core.remote.cache import FetcherFactory + +logger = logging.getLogger(__name__) + + +class SSPInheritanceAPI(): + """API for updating inheritance information in SSPs through inheritance markdown.""" + + def __init__(self, inheritance_md_path: pathlib.Path, trestle_root: pathlib.Path) -> None: + """Initialize the SSP Inheritance API class.""" + self._inheritance_markdown_path: pathlib.Path = inheritance_md_path + self._trestle_root: pathlib.Path = trestle_root + + def write_inheritance_as_markdown( + self, leveraged_ssp_reference: str, catalog_api: Optional[CatalogAPI] = None + ) -> None: + """ + Write inheritance information to markdown. + + Args: + leveraged_ssp_reference: Location of the SSP to write inheritance information from. + catalog_api: Catalog API to filter inheritance information by catalog. + + Notes: + If a catalog API is provided, the written controls in the markdown will be filtered by the catalog. + """ + leveraged_ssp: ossp.SystemSecurityPlan = self._fetch_leveraged_ssp(leveraged_ssp_reference) + + if catalog_api is not None: + control_imp: ossp.ControlImplementation = leveraged_ssp.control_implementation + + new_imp_requirements: List[ossp.ImplementedRequirement] = [] + for imp_requirement in as_list(control_imp.implemented_requirements): + control = catalog_api._catalog_interface.get_control(imp_requirement.control_id) + if control is not None: + new_imp_requirements.append(imp_requirement) + control_imp.implemented_requirements = new_imp_requirements + + leveraged_ssp.control_implementation = control_imp + + export_writer: ExportWriter = ExportWriter( + self._inheritance_markdown_path, leveraged_ssp, leveraged_ssp_reference + ) + export_writer.write_exports_as_markdown() + + def update_ssp_inheritance(self, ssp: ossp.SystemSecurityPlan) -> None: + """ + Update inheritance information in SSP. + + Args: + ssp: SSP to update with inheritance information. + """ + logger.debug('Reading inheritance information from markdown.') + reader = ExportReader(self._inheritance_markdown_path, ssp) + ssp = reader.read_exports_from_markdown() + + leveraged_ssp_reference = reader.get_leveraged_ssp_href() + + leveraged_ssp: ossp.SystemSecurityPlan = self._fetch_leveraged_ssp(leveraged_ssp_reference) + + link: common.Link = common.Link(href=leveraged_ssp_reference) + leveraged_auths: List[ossp.LeveragedAuthorization] = [] + leveraged_auth: ossp.LeveragedAuthorization = gens.generate_sample_model(ossp.LeveragedAuthorization) + leveraged_components: List[str] = reader.get_leveraged_components() + + if not leveraged_components: + logger.warn( + 'No leveraged components mapped to the SSP. ' + 'Please edit the inheritance markdown to include the leveraged authorization.' + ) + else: + existing_leveraged_auth: ossp.LeveragedAuthorization = self._leveraged_auth_from_existing( + as_list(ssp.system_implementation.leveraged_authorizations), link + ) + if existing_leveraged_auth is not None: + leveraged_auth = existing_leveraged_auth + else: + leveraged_auth.links = as_list(leveraged_auth.links) + leveraged_auth.links.append(link) + + leveraged_auth.title = f'Leveraged Authorization for {leveraged_ssp.metadata.title}' + leveraged_auths.append(leveraged_auth) + + # Overwrite the leveraged authorization in the SSP. The only leveraged authorization should be the one + # coming from inheritance view + ssp.system_implementation.leveraged_authorizations = none_if_empty(leveraged_auths) + + self._reconcile_components(ssp, leveraged_ssp, leveraged_components, leveraged_auth) + + def _fetch_leveraged_ssp(self, leveraged_ssp_reference: str) -> ossp.SystemSecurityPlan: + """Fetch the leveraged SSP.""" + leveraged_ssp: ossp.SystemSecurityPlan + fetcher = FetcherFactory.get_fetcher(self._trestle_root, leveraged_ssp_reference) + try: + leveraged_ssp, _ = fetcher.get_oscal() + except TrestleError as e: + raise TrestleError(f'Unable to fetch ssp from {leveraged_ssp_reference}: {e}') + return leveraged_ssp + + def _reconcile_components( + self, + ssp: ossp.SystemSecurityPlan, + leveraged_ssp: ossp.SystemSecurityPlan, + leveraged_components: List[str], + leveraged_auth: ossp.LeveragedAuthorization + ) -> None: + """Reconcile components in the leveraging SSP with those in the leveraged SSP.""" + mapped_components: Dict[str, ossp.SystemComponent] = {} + for component in as_list(leveraged_ssp.system_implementation.components): + if component.title in leveraged_components: + mapped_components[component.uuid] = component + + new_components: List[ossp.SystemComponent] = [] + for component in as_list(ssp.system_implementation.components): + props_dict: Dict[str, str] = {prop.name: prop.value for prop in as_list(component.props)} + + # If this component is part of the original SSP components, add + # and continue + if const.LEV_AUTH_UUID not in props_dict: + new_components.append(component) + continue + + # If the leveraged component already exists, update the title, description, type, and status + original_comp_uuid = props_dict[const.INHERITED_UUID] + if original_comp_uuid in mapped_components: + original_component = mapped_components.pop(original_comp_uuid) + self._update_leveraged_system_component(component, original_component, leveraged_auth.uuid) + new_components.append(component) + + # Add any remaining components to the new components + for component in mapped_components.values(): + new_component: ossp.SystemComponent = gens.generate_sample_model(ossp.SystemComponent) + self._update_leveraged_system_component(new_component, component, leveraged_auth.uuid) + logger.debug(f'Adding component {new_component.title} to components.') + new_components.append(new_component) + + ssp.system_implementation.components = new_components + + @staticmethod + def _update_leveraged_system_component( + new_comp: ossp.SystemComponent, original_comp: ossp.SystemComponent, leveraged_auth_id: str + ) -> None: + """Create a leveraged system component in the context of a leveraging system component.""" + new_comp.type = original_comp.type + new_comp.title = original_comp.title + new_comp.description = original_comp.description + new_comp.status = original_comp.status + + new_comp.props = [ + common.Property(name=const.IMPLEMENTATION_POINT, value=const.IMPLEMENTATION_POINT_EXTERNAL), + common.Property(name=const.LEV_AUTH_UUID, value=leveraged_auth_id), + common.Property(name=const.INHERITED_UUID, value=original_comp.uuid) + ] + + def _leveraged_auth_from_existing( + self, leveraged_authorizations: List[ossp.LeveragedAuthorization], criteria_link: common.Link + ) -> Optional[ossp.LeveragedAuthorization]: + """Return the leveraged authorization if it is present in the ssp.""" + for leveraged_auth in leveraged_authorizations: + if leveraged_auth.links and any(link.href == criteria_link.href for link in leveraged_auth.links): + return leveraged_auth + return None diff --git a/trestle/core/repository.py b/trestle/core/repository.py index 2b89305de..b53962a48 100644 --- a/trestle/core/repository.py +++ b/trestle/core/repository.py @@ -620,6 +620,7 @@ def generate_ssp_markdown( profile: str, output: str, compdefs: str, + leveraged_ssp: str = '', force_overwrite: bool = False, yaml_header: str = '', overwrite_header_values: bool = False @@ -633,6 +634,7 @@ def generate_ssp_markdown( profile=profile, output=output, compdefs=compdefs, + leveraged_ssp=leveraged_ssp, trestle_root=self.root_dir, force_overwrite=force_overwrite, yaml_header=yaml_header,