diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml
index 76521caf6..c50160cee 100644
--- a/.github/workflows/python-test.yml
+++ b/.github/workflows/python-test.yml
@@ -92,6 +92,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: [3.8, 3.9]
include:
- os: ubuntu-latest
path: ~/.cache/pip
@@ -99,7 +100,19 @@ jobs:
path: ~/Library/Caches/pip
- os: windows-latest
path: ~\AppData\Local\pip\Cache
- python-version: [3.7, 3.8, 3.9]
+ # optional 3.7 test
+ - os: ubuntu-latest
+ path: ~/.cache/pip
+ python-version: 3.7
+ # optional 3.7 test
+ - os: macos-latest
+ path: ~/Library/Caches/pip
+ python-version: 3.7.16
+ # optional 3.7 test
+ - os: windows-latest
+ path: ~\AppData\Local\pip\Cache
+ python-version: 3.7
+
steps:
- name: Don't mess with line endings
run: |
@@ -122,16 +135,16 @@ jobs:
run: |
make develop
- name: Pytest Fast
- if: ${{ !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.7') }}
+ if: ${{ !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8') }}
run: |
make test
- name: Pytest Cov
- if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.7' }}
+ if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }}
run: |
make test-cov
- name: Upload artifact
- if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.7' }}
+ if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }}
uses: actions/upload-artifact@v2
with:
name: coverage
@@ -154,7 +167,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v2
with:
- python-version: 3.7
+ python-version: 3.8
- uses: actions/cache@v2
with:
path: ~/.cache/pip
@@ -178,7 +191,7 @@ jobs:
-Dsonar.python.coverage.reportPaths=coverage.xml
-Dsonar.tests=tests/
-Dsonar.sources=trestle/
- -Dsonar.python.version=3.7
+ -Dsonar.python.version=3.8
-Dsonar.projectKey=compliance-trestle
-Dsonar.organization=compliance-trestle
-Dsonar.cpd.exclusions=trestle/oscal/*.py
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index 21e60e2de..a97300306 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -1,23 +1,15 @@
Trestle was designed and open sourced by a team based at [IBM Research](https://www.research.ibm.com/) and others around the world. The list includes:
-Christopher Butler - [butler54](https://github.com/butler54)
-
-Bruno Marques - [brunomarq](https://github.com/brunomarq)
+Alejandro Jose Leiva Palomo [AleJo2995](https://github.com/AleJo2995)
-Lenin Mehedy - [leninmehedy](https://github.com/leninmehedy)
+Christopher Butler [butler54](https://github.com/butler54)
-Simon Metson - [drsm79](https://github.com/drsm79)
+Lou Degenaro [degenaro](https://github.com/degenaro)
-Frank Suits - [fsuits](https://github.com/fsuits)
+Frank Suits [fsuits](https://github.com/fsuits)
-Jeff Tan - [jeffdmgit](https://github.com/jeffdmgit)
+Jennifer Power [jpower432](https://github.com/jpower432)
-Nebula Alam - [aNebula](https://github.com/aNebula)
+Manjiree Gadgil [mrgadgil](https://github.com/mrgadgil)
Vikas Agarwal [vikas-agarwal76](https://github.com/vikas-agarwal76)
-
-Lou Degenaro [degenaro](https://github.com/degenaro)
-
-Ekaterina Nikonova [enikonovad](https://github.com/enikonovad)
-
-Alejandro Jose Leiva Palomo [AleJo2995](https://github.com/AleJo2995)
diff --git a/README.md b/README.md
index dff5206ab..20b87dd1a 100644
--- a/README.md
+++ b/README.md
@@ -88,23 +88,33 @@ Compliance trestle is currently stable and is based on NIST OSCAL version 1.0.4,
## Community call
We would like to share development in progress for compliance trestle, coming soon and get feedback from community on what features would they like to see in compliance trestle.\
-The community call will happen every 2 week(s) on Tuesday at 10am EST.\
+The community call will happen every 2 week(s) on Tuesday at 10.00am EST.\
Meeting information:
```
+You have been invited to Attend at the following event :
Compliance Trestle Community Call
-Hosted by MANJIREE GADGIL
-
-https://ibm.webex.com/ibm/j.php?MTID=m46740e85f87f290d0848c6941c489b0a
-Tuesday, May 16, 2023 10:30 AM | 30 minutes | (UTC-04:00) Eastern Time (US & Canada)
-Occurs every 2 week(s) on Tuesday effective 5/16/2023 from 10:30 AM to 11:00 AM, (UTC-04:00) Eastern Time (US & Canada)
-
-Join by phone
-1-844-531-0958 United States Toll Free
-1-669-234-1178 United States Toll
-1-669-234-1178 United States Toll
-Host access code 979 191 85
-Attendee access code 979 379 85
+Every 2nd week on Tuesday; from June 27th, 2023 at 10:00 am EST for 0.5 hour
+
+To join, select from the following options:
+
+1) Web Browser
+ a) https://primetime.bluejeans.com/a2m/live-event/dcwuavtj
+
+2) Laptop paired with room system (Best Experience)
+ a) Dial: meet@bjn.vc or 104.238.247.247 in the room system.
+ b) Go to https://primetime.bluejeans.com/a2m/live-event/dcwuavtj/room-system/
+ c) Enter the pairing code displayed on your room system's screen into your browser.
+
+3) Room System
+ a) Dial: meet@bjn.vc or 104.238.247.247 in the room system.
+ b) Enter Meeting ID : 499830564 and Passcode : 8231
+
+4) Joining via a mobile device?
+ a) Open this link : https://primetime.bluejeans.com/a2m/live-event/dcwuavtj
+ b) Download the app if you don’t have it already
+ c) Enter event ID : dcwuavtj
+
```
## Contributing to Trestle
diff --git a/docs/api_reference/trestle.tasks.oscal_catalog_to_csv.md b/docs/api_reference/trestle.tasks.oscal_catalog_to_csv.md
new file mode 100644
index 000000000..af67ad272
--- /dev/null
+++ b/docs/api_reference/trestle.tasks.oscal_catalog_to_csv.md
@@ -0,0 +1,2 @@
+::: trestle.tasks.oscal_catalog_to_csv
+handler: python
diff --git a/docs/trestle_author.md b/docs/trestle_author.md
index 780882599..6c5c9bcb8 100644
--- a/docs/trestle_author.md
+++ b/docs/trestle_author.md
@@ -591,6 +591,21 @@ CLI evocation:
The `profile` author commands allow you to edit additions made by a profile to its imported controls that end up in the final resolved profile catalog. Only the additions may be edited or added to the generated markdown control files - and those additions can then be assembled into a new version of the original profile, with those additions. For more details on its usage please see [the profile authoring tutorial](https://ibm.github.io/compliance-trestle/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring).
+### Profile generation with inheritance
+
+CLI evocation:
+
+> trestle author profile-inherit
+
+The `profile-inherit` sub-command takes a given parent profile and filters its imported controls based inherited controls from a given SSP.
+
+The leveraged SSP is evaluated based on whether provided and responsibility statements for all `by-component` fields are set for each applicable control, as well as the implementation status.
+All components must have exported provided statements, no exported responsibility statements, and an implementation status of `implemented` in order for a control to be filtered from the output profile (i.e. controls delta profile).
+
+As with the other related author commands, if an existing destination file already exists, it is not updated if no changes would be made.
+
+For more details on its usage please see [the ssp-filter tutorial](https://ibm.github.io/compliance-trestle/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring).
+
### SSP authoring
CLI evocation:
@@ -613,15 +628,17 @@ CLI evocation:
> trestle author ssp-filter
-The `ssp-filter` sub-command takes a given SSP and filters its contents based on a given profile, list of components, and/or control implementation status.
+The `ssp-filter` sub-command takes a given SSP and filters its contents based on a given profile, list of components, control implementation status and/or control origination.
If filtering by profile, the SSP is assumed to contain a superset of controls needed by the profile, and the filter operation generates a new SSP with just the controls needed by that profile. If the profile references a control not in the SSP, the routine fails with an error.
-If filtering by components, a colon-delimited list of components should be provided, with `This system` as the default name for the overall required component for the entire system. Case and spaces are ignored in the component names, so the names could be specified as `--components "this system: my component"`. The resulting, filtered ssp will have updated implementated requirements with filtered by_components on each requirement, and filtered by_components on each statement.
+If filtering by components, a colon-delimited list of components should be provided, with `This system` as the default name for the overall required component for the entire system. Case and spaces are ignored in the component names, so the names could be specified as `--components "this system: my component"`. The resulting, filtered ssp will have updated implemented requirements with filtered by_components on each requirement, and filtered by_components on each statement.
+
+If filtering by control implementation status, a comma-delimited list of implementation status values should be provided. These values must comply with the OSCAL SSP format references's allowed values, which are as follows: implemented, partial, planned, alternative, and not-applicable.
-If filtering by control implementation status, a comma-demilited list of implementation status values should be provided. These values must comply with the OSCAL SSP format references's allowed values, which are as follows: implemented, partial, planned, alternative, and not-applicable.
+If filtering by control origination, a comma-delimited list of control origination values should be provided. These values must comply with the OSCAL SSP format references's allowed values for the control origination property, which are as follows: system-specific, inherited, organization, customer-configured, and customer-provided.
-You may filter by a combination of a profile, list of component names, and implementation statuses.
+You may filter by a combination of a profile, list of component names, implementation statuses, and control origination values.
As with the other related author commands, if an existing destination file already exists, it is not updated if no changes would be made.
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 6639bc64e..38a11b252 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
@@ -27,6 +27,7 @@ The author commands are:
1. `catalog-generate` converts a control Catalog to individual controls in markdown format for addition or editing of guidance prose and parameters, with parameters stored in a yaml header at the top of the markdown file. `catalog-assemble` then gathers the prose and parameters and updates the controls in the Catalog to make a new OSCAL Catalog.
1. `profile-generate` takes a given Profile and converts the controls represented by its resolved profile catalog to individual controls in markdown format, with sections corresponding to the content that the Profile adds to the Catalog, along with both the current values of parameters in the resolved profile catalog - and the values that are being modified by the given profile's SetParameters. The user may edit the content or add more, and `profile-assemble` then gathers the updated content and creates a new OSCAL Profile that includes those changes.
1. `profile-resolve` is special as an authoring tool because it does not involve markdown and instead it simply creates a JSON resolved profile catalog from a specified JSON profile in the trestle directory. There are options to specify whether or not parameters get replace in the control prose or not, along with any special brackets that might be desired to indicate the parameters embedded in the prose.
+1. `profile-inherit` takes a given parent profile and filters its contents based on the inherited controls included in a given ssp to be include in the final profile.
1. `component-generate` takes a given ComponentDefinition file and represents all the controls in markdown in separate directories for each Component in the file. This allows editing of the prose on a per-component basis. `component-assemble` then assembles the markdown for all controls in all component directories into a new, or the same, ComponentDefinition file.
1. `ssp-generate` takes a given Profile and an optional list of component-definitions, and represents the individual controls as markdown files with sections that prompt for prose regarding the implementation response for items in the statement of the control, with separate response sections for each component. `ssp-assemble` then gathers the response sections and creates an OSCAL System Security Plan comprising the resolved profile catalog and the implementation responses for each component. The list of component-definitions is optional, but without them the SSP will only have one component: `This System`. Rules, parameters and status associated with the implemented requirements are stored in the SetParameters and Properties of the components in the component definitions and represented in the markdown, allowing changes to be made to the parameter values and status. These edits are then included in the assembled SSP. Note that the rules themselves may not be edited and strictly correspond to what is in the component definitions.
1. `ssp-filter` takes a given ssp and filters its contents based on the controls included in a provided profile, or in a list of components to be included in the final ssp.
@@ -524,6 +525,51 @@ Similar options apply to the `jinja` authoring commands.
+trestle author profile-inherit
+
+The `trestle author profile-inherit` command is different from the `generate/assemble` commands because it doesn't involve markdown and instead
+it takes an parent profile and ssp and creates child profile in `JSON` format.
+
+When utilizing a process with leveraged authorizations, use the command `trestle author profile-inherit` to create a profile with initial content using a parent profile and SSP with inheritable controls. The provided and responsibility statements for all `by-component` fields, as well as the implementation status, will be used to evaluate the leveraged SSP.
+To be filtered from the output profile (i.e. controls delta profile), all components must have exported provided statements, no exported responsibility statements, and an implementation status of `implemented`.
+
+The filter command is invoked as:
+
+`trestle author profile-inherit --profile my_parent --ssp my_leveraged_ssp --output controls_delta_profile`
+
+Both the parent profile and the SSP must be present in the trestle workspace. This command produces a new workspace profile that imports the parent profile and filters the inherited controls from the SSP using the `exclude-controls` and `include-controls` fields in the profile import.
+
+
+
+Example imports generated from profile-inherit
+
+```json
+ "imports": [
+ {
+ "href": "trestle://profiles/controls_delta/profile.json",
+ "include-controls": [
+ {
+ "with-ids": [
+ "ac-2"
+ ]
+ }
+ ],
+ "exclude-controls": [
+ {
+ "with-ids": [
+ "ac-1"
+ ]
+ }
+ ]
+ }
+ ]
+```
+
+
+
+
+
+
trestle author component-generate and component-assemble
The `trestle author component-generate` command takes a JSON ComponentDefinition file and creates markdown for its controls in separate directories for each of the DefinedComponents in the file. This allows specifying the implementation response and status for each component separately in separate markdown files for a control. In addition, the markdown captures Rules in the control that specify descriptions and parameter values that apply to the expected responses.
@@ -1010,13 +1056,13 @@ If you do not specify component-defintions during assembly, the markdown should
trestle author ssp-filter
-Once you have an SSP in the trestle directory you can filter its contents with a profile, list of components, or list of implementation status values by using the command `trestle author ssp-filter`. The SSP is assumed to contain a superset of the controls needed by the profile if a profile is specified, and the filter operation will generate a new SSP with only those controls needed by the profile. If a list of component names is provided, only the specified components will appear in the system implementation of the ssp. If a list of implementation statuses is provided, controls with implementations including those statuses will appear in the control implementation of the ssp.
+Once you have an SSP in the trestle directory you can filter its contents with a profile, list of components, list of implementation statuses, or list control origination values by using the command `trestle author ssp-filter`. The SSP is assumed to contain a superset of the controls needed by the profile if a profile is specified, and the filter operation will generate a new SSP with only those controls needed by the profile. If a list of component names is provided, only the specified components will appear in the system implementation of the ssp. If a list of implementation statuses is provided, controls with implementations including those statuses will appear in the control implementation of the ssp. Similarly, if a list of control origination values is provided, implemented requirements with a control origination property value included in the provided values will appear in the control implementation of the ssp.
The filter command is invoked as:
-`trestle author ssp-filter --name my_ssp --profile my_profile --components comp_a:comp_b --implementation-status "planned,partial" --output my_culled_ssp`
+`trestle author ssp-filter --name my_ssp --profile my_profile --components comp_a:comp_b --implementation-status "planned,partial" --control-origination "customer-configured" --output my_culled_ssp`
-The SSP must be present in the trestle workspace and, if filtering by profile, that profile must also be in the trestle workspace. This command will generate a new SSP in the workspace. If the profile makes reference to a control not in the SSP then the routine will fail with an error message. Similarly, if one of the components is not present in the ssp the routine will also fail. The implementation statuses must be one of the allowed values as defined in the OSCAL SSP JSON format reference. Those include the following: implemented, partial, planned, alternative, and not-applicable. If an invalid value is provided, an error is returned.
+The SSP must be present in the trestle workspace and, if filtering by profile, that profile must also be in the trestle workspace. This command will generate a new SSP in the workspace. If the profile makes reference to a control not in the SSP then the routine will fail with an error message. Similarly, if one of the components is not present in the ssp the routine will also fail. The implementation statuses must be one of the allowed values as defined in the OSCAL SSP JSON format reference. Those include the following: implemented, partial, planned, alternative, and not-applicable. If an invalid value is provided, an error is returned. The control origination values also must be one of the allowed values as defined in the OSCAL SSP JSON format reference. Those include the following: system-specific, inherited, customer-configured, customer-provided, and organization. If an invalid value is provided, an error is returned.
diff --git a/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_options.docx b/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_options.docx
index 854dcd19d..dcbf91f9c 100644
Binary files a/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_options.docx and b/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_options.docx differ
diff --git a/docs/tutorials/ssp_profile_catalog_authoring/trestle_ssp_author_options.png b/docs/tutorials/ssp_profile_catalog_authoring/trestle_ssp_author_options.png
index 91dbf3a86..d08612775 100644
Binary files a/docs/tutorials/ssp_profile_catalog_authoring/trestle_ssp_author_options.png and b/docs/tutorials/ssp_profile_catalog_authoring/trestle_ssp_author_options.png differ
diff --git a/mkdocs.yml b/mkdocs.yml
index a7c880c10..38b112540 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -167,6 +167,7 @@ nav:
- csv_to_oscal_cd: api_reference/trestle.tasks.csv_to_oscal_cd.md
- ocp4_cis_profile_to_oscal_catalog: api_reference/trestle.tasks.ocp4_cis_profile_to_oscal_catalog.md
- ocp4_cis_profile_to_oscal_cd: api_reference/trestle.tasks.ocp4_cis_profile_to_oscal_cd.md
+ - oscal_catalog_to_csv: api_reference/trestle.tasks.oscal_catalog_to_csv.md
- oscal_profile_to_osco_profile: api_reference/trestle.tasks.oscal_profile_to_osco_profile.md
- osco_result_to_oscal_ar: api_reference/trestle.tasks.osco_result_to_oscal_ar.md
- tanium_result_to_oscal_ar: api_reference/trestle.tasks.tanium_result_to_oscal_ar.md
diff --git a/tests/data/jinja/profile_to_docs_no_part_prose.md.jinja b/tests/data/jinja/profile_to_docs_no_part_prose.md.jinja
new file mode 100644
index 000000000..d92b30a1b
--- /dev/null
+++ b/tests/data/jinja/profile_to_docs_no_part_prose.md.jinja
@@ -0,0 +1,17 @@
+# Control Page
+
+{{
+control_writer.write_control_with_sections(control, profile, group_title,
+['statement', 'objective', 'ExpectedEvidence', 'guidance', 'table_of_parameters', 'instructions', 'additional_instructions'],
+{'statement':'Statement Header',
+'objective':'Control Objective Header',
+'guidance':'Implementation Guidance',
+'instructions': 'Instructions With Prose',
+'additional_instructions': 'Additional Instructions (No Prose)',
+'table_of_parameters':'Table of Control Parameters'
+},
+label_column=True,
+add_group_to_title=False
+)
+| safe
+}}
diff --git a/tests/data/json/comp_def_b.json b/tests/data/json/comp_def_b.json
index 607b80c72..9490f2a35 100644
--- a/tests/data/json/comp_def_b.json
+++ b/tests/data/json/comp_def_b.json
@@ -166,6 +166,14 @@
{
"name": "implementation-status",
"value": "implemented"
+ },
+ {
+ "name": "control-origination",
+ "value": "system-specific"
+ },
+ {
+ "name": "control-origination",
+ "value": "customer-configured"
}
],
"responsible-roles": [
@@ -300,6 +308,10 @@
{
"name": "implementation-status",
"value": "implemented"
+ },
+ {
+ "name": "control-origination",
+ "value": "system-specific"
}
],
"responsible-roles": [
diff --git a/tests/data/json/comp_prof_part_none.json b/tests/data/json/comp_prof_part_none.json
new file mode 100644
index 000000000..9c8caff8f
--- /dev/null
+++ b/tests/data/json/comp_prof_part_none.json
@@ -0,0 +1,89 @@
+{
+"profile": {
+ "uuid": "A0000000-0000-4000-8000-000000000014",
+ "metadata": {
+ "title": "comp prof aa",
+ "last-modified": "2021-01-01T00:00:00.000+00:00",
+ "version": "2021-01-01",
+ "oscal-version": "1.0.0"
+ },
+ "imports": [
+ {
+ "href": "trestle://catalogs/simplified_nist_catalog/catalog.json",
+ "include-controls": [
+ {
+ "with-ids": [
+ "ac-1",
+ "ac-2",
+ "ac-2.1",
+ "ac-3",
+ "at-1",
+ "ac-6.7",
+ "ac-4",
+ "at-2"
+ ]
+ }
+ ]
+ }
+ ],
+ "merge": {
+ "as-is": true
+ },
+ "modify": {
+ "alters": [
+ {
+ "control-id": "ac-1",
+ "adds": [
+ {
+ "position": "ending",
+ "parts": [
+ {
+ "id": "ac-1_instructions",
+ "name": "instructions",
+ "prose": "A set of instructions for executors to follow 1."
+ },
+ {
+ "id": "ac-1_additional_instructions",
+ "name": "additional_instructions",
+ "parts": [
+ {
+ "id": "ac-1_additional_instructions.a",
+ "name": "a",
+ "parts": [
+ {
+ "id": "ac-1_additional_instructions.a.some_detail_add_instr_1",
+ "name": "some_detail_add_instr_1"
+ },
+ {
+ "id": "ac-1_additional_instructions.a.some_detail_add_instr_2",
+ "name": "some_detail_add_instr_2",
+ "prose": "More text on how to follow instructions - a2."
+ }
+ ]
+ },
+ {
+ "id": "ac-1_additional_instructions.b",
+ "name": "b",
+ "parts": [
+ {
+ "id": "ac-1_additional_instructions.b.some_detail_add_instr_1",
+ "name": "some_detail_add_instr_1",
+ "prose": "More text on how to follow instructions - b1"
+ },
+ {
+ "id": "ac-1_additional_instructions.b.some_detail_add_instr_1",
+ "name": "some_detail_add_instr_2",
+ "prose": "More text on how to follow instructions - b2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/data/json/leveraged_ssp.json b/tests/data/json/leveraged_ssp.json
new file mode 100644
index 000000000..c55392fcb
--- /dev/null
+++ b/tests/data/json/leveraged_ssp.json
@@ -0,0 +1,343 @@
+{
+ "system-security-plan": {
+ "uuid": "A0000000-0000-4000-8000-000000000036",
+ "metadata": {
+ "title": "Example Leveraged System Security Plan",
+ "last-modified": "2021-06-08T13:57:35.068496-04:00",
+ "version": "0.1",
+ "oscal-version": "1.0.0",
+ "roles": [
+ {
+ "id": "admin",
+ "title": "Administrator"
+ },
+ {
+ "id": "customer",
+ "title": "External Customer"
+ },
+ {
+ "id": "poc-for-customers",
+ "title": "Internal POC for Customers"
+ }
+ ],
+ "parties": [
+ {
+ "uuid": "11111111-0000-4000-9000-100000000001",
+ "type": "person"
+ }
+ ]
+ },
+ "import-profile": {
+ "href": "trestle://profiles/simple_test_profile/profile.json"
+ },
+ "system-characteristics": {
+ "system-ids": [
+ {
+ "id": "csp_iaas_system"
+ }
+ ],
+ "system-name": "Leveraged IaaS 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 +-------------------+\n |\n |\n +-------------------+\n | Leveraged IaaS |\n | this file |\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 IaaS 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 hardware and software supporting the virtualized infrastructure supporting the IaaS."
+ },
+ "remarks": "Most system-characteristics content does not support the example, and is included to meet the minimum SSP syntax requirements."
+ },
+ "system-implementation": {
+ "users": [
+ {
+ "uuid": "11111111-0000-4000-9000-200000000001",
+ "role-ids": [
+ "admin"
+ ],
+ "authorized-privileges": [
+ {
+ "title": "Administrator",
+ "functions-performed": [
+ "Manages the components within the IaaS."
+ ]
+ }
+ ]
+ }
+ ],
+ "components": [
+ {
+ "uuid": "cfbc1d9d-e772-47a4-aed5-1b902339eab2",
+ "type": "this-system",
+ "title": "This System",
+ "description": "The system described by this SSP.",
+ "status": {
+ "state": "operational"
+ }
+ },
+ {
+ "uuid": "11111111-0000-4000-9001-000000000001",
+ "type": "this-system",
+ "title": "This System",
+ "description": "This Leveraged IaaS.\n\nThe entire system as depicted in the system authorization boundary",
+ "status": {
+ "state": "operational"
+ }
+ },
+ {
+ "uuid": "11111111-0000-4000-9001-000000000002",
+ "type": "software",
+ "title": "Application",
+ "description": "An application within the IaaS, exposed to SaaS customers and their downstream customers.\n\nThis Leveraged IaaS maintains aspects of the application.\n\nThe Leveraging SaaS maintains aspects of their assigned portion of the application.\n\nThe customers of the Leveraging SaaS maintain aspects of their sub-assigned portions of the application.",
+ "props": [
+ {
+ "name": "implementation-point",
+ "value": "system"
+ }
+ ],
+ "status": {
+ "state": "operational"
+ },
+ "responsible-roles": [
+ {
+ "role-id": "admin",
+ "party-uuids": [
+ "11111111-0000-4000-9000-100000000001"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "control-implementation": {
+ "description": "This is a collection of control responses.",
+ "implemented-requirements": [
+ {
+ "uuid": "11111111-0000-4000-9009-001000000000",
+ "control-id": "ac-1",
+ "statements": [
+ {
+ "statement-id": "ac-1_stmt.a",
+ "uuid": "11111111-0000-4000-9009-001001000000",
+ "by-components": [
+ {
+ "component-uuid": "11111111-0000-4000-9001-000000000001",
+ "uuid": "11111111-0000-4000-9009-001001001001",
+ "description": "Response for the \\\"This System\\\" component.\n\nOverall description of how \\\"This System\\\" satisfies AC-1, 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.",
+ "implementation-status": {
+ "state": "implemented"
+ },
+ "export": {
+ "description": "Optional description about what is being exported.",
+ "provided": [
+ {
+ "uuid": "11111111-0000-4000-9009-001002002001",
+ "description": "Consumer-appropriate description of what may be inherited.\n\nIn the context of the application component in satisfaction of AC-1, part a.",
+ "responsible-roles": [
+ {
+ "role-id": "poc-for-customers"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "component-uuid": "11111111-0000-4000-9001-000000000002",
+ "uuid": "11111111-0000-4000-9009-001001002005",
+ "description": "Describes how the application satisfies AC-1, Part a.",
+ "implementation-status": {
+ "state": "implemented"
+ },
+ "export": {
+ "description": "Optional description about what is being exported.",
+ "provided": [
+ {
+ "uuid": "11111111-0000-4000-9009-001001002006",
+ "description": "Consumer-appropriate description of what may be inherited.\n\nIn the context of the application component in satisfaction of AC-1, part a.",
+ "responsible-roles": [
+ {
+ "role-id": "poc-for-customers"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "uuid": "11111111-0000-4000-9009-002000000000",
+ "control-id": "ac-2",
+ "set-parameters": [
+ {
+ "param-id": "ac-2_prm_1",
+ "values": [
+ "privileged and non-privileged"
+ ]
+ }
+ ],
+ "statements": [
+ {
+ "statement-id": "ac-2_stmt.a",
+ "uuid": "11111111-0000-4000-9009-002001000000",
+ "by-components": [
+ {
+ "component-uuid": "11111111-0000-4000-9001-000000000001",
+ "uuid": "11111111-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.",
+ "export": {
+ "description": "Optional description about what is being exported.",
+ "responsibilities": [
+ {
+ "uuid": "11111111-0000-4000-9009-002001001001",
+ "description": "Leveraging system's responsibilities with respect to inheriting this capability.\n\nIn the context of the application component in satisfaction of AC-2, part a.",
+ "responsible-roles": [
+ {
+ "role-id": "customer"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "component-uuid": "11111111-0000-4000-9001-000000000002",
+ "uuid": "11111111-0000-4000-9009-002001002000",
+ "description": "Describes how the application satisfies AC-2, Part a.",
+ "export": {
+ "description": "Optional description about what is being exported.",
+ "provided": [
+ {
+ "uuid": "11111111-0000-4000-9009-002001002001",
+ "description": "Consumer-appropriate description of what may be inherited.\n\nIn the context of the application component in satisfaction of AC-2, part a.",
+ "responsible-roles": [
+ {
+ "role-id": "poc-for-customers"
+ }
+ ]
+ }
+ ],
+ "responsibilities": [
+ {
+ "uuid": "11111111-0000-4000-9009-002001002002",
+ "provided-uuid": "11111111-0000-4000-9009-002001002001",
+ "description": "Leveraging system's responsibilities with respect to inheriting this capability.\n\nIn the context of the application component in satisfaction of AC-2, part a.",
+ "responsible-roles": [
+ {
+ "role-id": "customer"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "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\nd. through j. omitted"
+ },
+ {
+ "uuid": "11111111-0000-4000-9009-003000000000",
+ "control-id": "ac-2.1",
+ "by-components": [
+ {
+ "component-uuid": "11111111-0000-4000-9001-000000000001",
+ "uuid": "11111111-0000-4000-9009-001002001001",
+ "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.1.\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.",
+ "implementation-status": {
+ "state": "planned"
+ },
+ "export": {
+ "description": "Optional description about what is being exported.",
+ "provided": [
+ {
+ "uuid": "11111111-0000-4000-9009-001001002001",
+ "description": "Consumer-appropriate description of what may be inherited.\n\nIn the context of the application component in satisfaction of AC-2.1.",
+ "responsible-roles": [
+ {
+ "role-id": "poc-for-customers"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "component-uuid": "11111111-0000-4000-9001-000000000002",
+ "uuid": "11111111-0000-4000-9009-001001002003",
+ "description": "Describes how the application satisfies AC-2, Part a.",
+ "implementation-status": {
+ "state": "implemented"
+ },
+ "export": {
+ "description": "Optional description about what is being exported.",
+ "provided": [
+ {
+ "uuid": "11111111-0000-4000-9009-001001002004",
+ "description": "Consumer-appropriate description of what may be inherited.\n\nIn the context of the application component in satisfaction of AC-2.1.",
+ "responsible-roles": [
+ {
+ "role-id": "poc-for-customers"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "back-matter": {
+ "resources": [
+ {
+ "uuid": "11111111-0000-4000-9999-000000000001",
+ "rlinks": [
+ {
+ "href": "./attachments/IaaS_ac_proc.docx"
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/data/json/leveraged_ssp_readme.md b/tests/data/json/leveraged_ssp_readme.md
new file mode 100644
index 000000000..0e58eb832
--- /dev/null
+++ b/tests/data/json/leveraged_ssp_readme.md
@@ -0,0 +1,7 @@
+leveraged_ssp:
+
+> loads from simple test profile
+
+- ac-1 is fully implemented by the provider
+- ac-2 is shared between the provider and customer
+- ac-2.1 has provided export statements from the provider, but is not fully implemented.
diff --git a/tests/data/json/simple_test_profile_less.json b/tests/data/json/simple_test_profile_less.json
new file mode 100644
index 000000000..22b600c89
--- /dev/null
+++ b/tests/data/json/simple_test_profile_less.json
@@ -0,0 +1,196 @@
+{
+ "profile": {
+ "uuid": "A0000000-0000-4000-8000-000000000052",
+ "metadata": {
+ "title": "Trestle test profile",
+ "last-modified": "2021-01-01T00:00:00.000+00:00",
+ "version": "2021-01-01",
+ "oscal-version": "1.0.0"
+ },
+ "imports": [
+ {
+ "href": "#657e15f4-bee9-45fb-a43d-44d7f7f2abfa",
+ "include-controls": [
+ {
+ "with-ids": [
+ "ac-1",
+ "ac-2"
+ ]
+ }
+ ]
+ }
+ ],
+ "merge": {
+ "combine": {
+ "method": "use-first"
+ },
+ "as-is": true
+ },
+ "modify": {
+ "set_parameters": [
+ {
+ "param_id": "ac-1_prm_1",
+ "props": [
+ {
+ "name": "display-name",
+ "ns": "https://ibm.github.io/compliance-trestle/schemas/oscal",
+ "value": "Pretty ac-1 prm 1"
+ }
+ ],
+ "label": "label from profile",
+ "values": [
+ "all personnel"
+ ]
+ },
+ {
+ "param_id": "ac-1_prm_2",
+ "props": [
+ {
+ "name": "display-name",
+ "value": "Pretty ac-1 prm 2"
+ }
+ ],
+ "values": [
+ "Organization-level",
+ "System-level"
+ ]
+ },
+ { "param_id": "ac-1_prm_3",
+ "values": [
+ "officer"
+ ]
+ },
+ { "param_id": "ac-1_prm_4",
+ "values": [
+ "weekly"
+ ]
+ },
+ { "param_id": "ac-1_prm_5",
+ "values": [
+ "all meetings"
+ ]
+ },
+ { "param_id": "ac-1_prm_6",
+ "values": [
+ "monthly"
+ ]
+ },
+ { "param_id": "ac-2_prm_1",
+ "values": [
+ "passwords"
+ ]
+ },
+ { "param_id": "ac-2_prm_2",
+ "values": [
+ "approvals"
+ ]
+ },
+ { "param_id": "ac-2_prm_3",
+ "values": [
+ "authorized personnel"
+ ]
+ },
+ { "param_id": "ac-2_prm_4",
+ "values": [
+ "guidelines"
+ ]
+ },
+ { "param_id": "ac-2_prm_5",
+ "values": [
+ "personnel"
+ ]
+ },
+ { "param_id": "ac-2_prm_6",
+ "values": [
+ "one week"
+ ]
+ },
+ { "param_id": "ac-2_prm_7",
+ "values": [
+ "one day"
+ ]
+ },
+ { "param_id": "ac-2_prm_8",
+ "values": [
+ "one hour"
+ ]
+ },
+ { "param_id": "ac-2_prm_9",
+ "values": [
+ "special needs"
+ ]
+ }
+ ],
+ "alters": [
+ {
+ "control_id": "ac-1",
+ "adds": [
+ {
+ "position": "after",
+ "by_id": "ac-1_smt",
+ "parts": [
+ {
+ "id": "ac-1_implgdn",
+ "name": "implgdn",
+ "prose": "Do it carefully."
+ },
+ {
+ "id": "ac-1_expevid",
+ "name": "expevid",
+ "prose": "Detailed logs."
+ }
+ ]
+ },
+ {
+ "position": "ending",
+ "props": [
+ {
+ "name": "prop_with_ns",
+ "value": "prop with ns",
+ "ns": "https://my_namespace"
+ },
+ {
+ "name": "prop_with_no_ns",
+ "value": "prop with no ns"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "control_id": "ac-2",
+ "adds": [
+ {
+ "position": "after",
+ "by_id": "ac-2_smt",
+ "parts": [
+ {
+ "id": "ac-2_implgdn",
+ "name": "implgdn",
+ "prose": "Maintain compliance"
+ },
+ {
+ "id": "ac-2_expevid",
+ "name": "expevid",
+ "prose": "Daily logs."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "back_matter": {
+ "resources": [
+ {
+ "uuid": "657e15f4-bee9-45fb-a43d-44d7f7f2abfa",
+ "rlinks": [
+ {
+ "href": "trestle://catalogs/nist_cat/catalog.json"
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/data/json/simple_test_profile_more.json b/tests/data/json/simple_test_profile_more.json
new file mode 100644
index 000000000..367ccd26f
--- /dev/null
+++ b/tests/data/json/simple_test_profile_more.json
@@ -0,0 +1,198 @@
+{
+ "profile": {
+ "uuid": "A0000000-0000-4000-8000-000000000051",
+ "metadata": {
+ "title": "Trestle test profile",
+ "last-modified": "2021-01-01T00:00:00.000+00:00",
+ "version": "2021-01-01",
+ "oscal-version": "1.0.0"
+ },
+ "imports": [
+ {
+ "href": "#657e15f4-bee9-45fb-a43d-44d7f7f2abfa",
+ "include-controls": [
+ {
+ "with-ids": [
+ "ac-1",
+ "ac-2",
+ "ac-2.1",
+ "ac-2.2"
+ ]
+ }
+ ]
+ }
+ ],
+ "merge": {
+ "combine": {
+ "method": "use-first"
+ },
+ "as-is": true
+ },
+ "modify": {
+ "set_parameters": [
+ {
+ "param_id": "ac-1_prm_1",
+ "props": [
+ {
+ "name": "display-name",
+ "ns": "https://ibm.github.io/compliance-trestle/schemas/oscal",
+ "value": "Pretty ac-1 prm 1"
+ }
+ ],
+ "label": "label from profile",
+ "values": [
+ "all personnel"
+ ]
+ },
+ {
+ "param_id": "ac-1_prm_2",
+ "props": [
+ {
+ "name": "display-name",
+ "value": "Pretty ac-1 prm 2"
+ }
+ ],
+ "values": [
+ "Organization-level",
+ "System-level"
+ ]
+ },
+ { "param_id": "ac-1_prm_3",
+ "values": [
+ "officer"
+ ]
+ },
+ { "param_id": "ac-1_prm_4",
+ "values": [
+ "weekly"
+ ]
+ },
+ { "param_id": "ac-1_prm_5",
+ "values": [
+ "all meetings"
+ ]
+ },
+ { "param_id": "ac-1_prm_6",
+ "values": [
+ "monthly"
+ ]
+ },
+ { "param_id": "ac-2_prm_1",
+ "values": [
+ "passwords"
+ ]
+ },
+ { "param_id": "ac-2_prm_2",
+ "values": [
+ "approvals"
+ ]
+ },
+ { "param_id": "ac-2_prm_3",
+ "values": [
+ "authorized personnel"
+ ]
+ },
+ { "param_id": "ac-2_prm_4",
+ "values": [
+ "guidelines"
+ ]
+ },
+ { "param_id": "ac-2_prm_5",
+ "values": [
+ "personnel"
+ ]
+ },
+ { "param_id": "ac-2_prm_6",
+ "values": [
+ "one week"
+ ]
+ },
+ { "param_id": "ac-2_prm_7",
+ "values": [
+ "one day"
+ ]
+ },
+ { "param_id": "ac-2_prm_8",
+ "values": [
+ "one hour"
+ ]
+ },
+ { "param_id": "ac-2_prm_9",
+ "values": [
+ "special needs"
+ ]
+ }
+ ],
+ "alters": [
+ {
+ "control_id": "ac-1",
+ "adds": [
+ {
+ "position": "after",
+ "by_id": "ac-1_smt",
+ "parts": [
+ {
+ "id": "ac-1_implgdn",
+ "name": "implgdn",
+ "prose": "Do it carefully."
+ },
+ {
+ "id": "ac-1_expevid",
+ "name": "expevid",
+ "prose": "Detailed logs."
+ }
+ ]
+ },
+ {
+ "position": "ending",
+ "props": [
+ {
+ "name": "prop_with_ns",
+ "value": "prop with ns",
+ "ns": "https://my_namespace"
+ },
+ {
+ "name": "prop_with_no_ns",
+ "value": "prop with no ns"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "control_id": "ac-2",
+ "adds": [
+ {
+ "position": "after",
+ "by_id": "ac-2_smt",
+ "parts": [
+ {
+ "id": "ac-2_implgdn",
+ "name": "implgdn",
+ "prose": "Maintain compliance"
+ },
+ {
+ "id": "ac-2_expevid",
+ "name": "expevid",
+ "prose": "Daily logs."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "back_matter": {
+ "resources": [
+ {
+ "uuid": "657e15f4-bee9-45fb-a43d-44d7f7f2abfa",
+ "rlinks": [
+ {
+ "href": "trestle://catalogs/nist_cat/catalog.json"
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/data/json/simple_test_profile_single.json b/tests/data/json/simple_test_profile_single.json
new file mode 100644
index 000000000..ac933b5e1
--- /dev/null
+++ b/tests/data/json/simple_test_profile_single.json
@@ -0,0 +1,129 @@
+{
+ "profile": {
+ "uuid": "A0000000-0000-4000-8000-000000000052",
+ "metadata": {
+ "title": "Trestle test profile",
+ "last-modified": "2021-01-01T00:00:00.000+00:00",
+ "version": "2021-01-01",
+ "oscal-version": "1.0.0"
+ },
+ "imports": [
+ {
+ "href": "#657e15f4-bee9-45fb-a43d-44d7f7f2abfa",
+ "include-controls": [
+ {
+ "with-ids": [
+ "ac-1"
+ ]
+ }
+ ]
+ }
+ ],
+ "merge": {
+ "combine": {
+ "method": "use-first"
+ },
+ "as-is": true
+ },
+ "modify": {
+ "set_parameters": [
+ {
+ "param_id": "ac-1_prm_1",
+ "props": [
+ {
+ "name": "display-name",
+ "ns": "https://ibm.github.io/compliance-trestle/schemas/oscal",
+ "value": "Pretty ac-1 prm 1"
+ }
+ ],
+ "label": "label from profile",
+ "values": [
+ "all personnel"
+ ]
+ },
+ {
+ "param_id": "ac-1_prm_2",
+ "props": [
+ {
+ "name": "display-name",
+ "value": "Pretty ac-1 prm 2"
+ }
+ ],
+ "values": [
+ "Organization-level",
+ "System-level"
+ ]
+ },
+ { "param_id": "ac-1_prm_3",
+ "values": [
+ "officer"
+ ]
+ },
+ { "param_id": "ac-1_prm_4",
+ "values": [
+ "weekly"
+ ]
+ },
+ { "param_id": "ac-1_prm_5",
+ "values": [
+ "all meetings"
+ ]
+ },
+ { "param_id": "ac-1_prm_6",
+ "values": [
+ "monthly"
+ ]
+ }
+ ],
+ "alters": [
+ {
+ "control_id": "ac-1",
+ "adds": [
+ {
+ "position": "after",
+ "by_id": "ac-1_smt",
+ "parts": [
+ {
+ "id": "ac-1_implgdn",
+ "name": "implgdn",
+ "prose": "Do it carefully."
+ },
+ {
+ "id": "ac-1_expevid",
+ "name": "expevid",
+ "prose": "Detailed logs."
+ }
+ ]
+ },
+ {
+ "position": "ending",
+ "props": [
+ {
+ "name": "prop_with_ns",
+ "value": "prop with ns",
+ "ns": "https://my_namespace"
+ },
+ {
+ "name": "prop_with_no_ns",
+ "value": "prop with no ns"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "back_matter": {
+ "resources": [
+ {
+ "uuid": "657e15f4-bee9-45fb-a43d-44d7f7f2abfa",
+ "rlinks": [
+ {
+ "href": "trestle://catalogs/nist_cat/catalog.json"
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/data/tasks/oscal-catalog-to-csv/test-oscal-catalog-to-csv-rev-5-by-control.config b/tests/data/tasks/oscal-catalog-to-csv/test-oscal-catalog-to-csv-rev-5-by-control.config
new file mode 100644
index 000000000..bc1af5d56
--- /dev/null
+++ b/tests/data/tasks/oscal-catalog-to-csv/test-oscal-catalog-to-csv-rev-5-by-control.config
@@ -0,0 +1,6 @@
+[task.oscal-catalog-to-csv]
+
+input-file = nist-content/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json
+output-dir = /tmp
+output-name = NIST_SP-800-53_rev5_catalog.by_control.csv
+level = control
\ No newline at end of file
diff --git a/tests/data/tasks/oscal-catalog-to-csv/test-oscal-catalog-to-csv-rev-5-by-statement.config b/tests/data/tasks/oscal-catalog-to-csv/test-oscal-catalog-to-csv-rev-5-by-statement.config
new file mode 100644
index 000000000..5edc31eef
--- /dev/null
+++ b/tests/data/tasks/oscal-catalog-to-csv/test-oscal-catalog-to-csv-rev-5-by-statement.config
@@ -0,0 +1,5 @@
+[task.oscal-catalog-to-csv]
+
+input-file = nist-content/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json
+output-dir = /tmp
+output-name = NIST_SP-800-53_rev5_catalog.by_statement.csv
\ No newline at end of file
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d8b251190..d167de1db 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -45,6 +45,7 @@
from trestle.oscal import common
from trestle.oscal import component as comp
from trestle.oscal import profile as prof
+from trestle.oscal import ssp
if file_utils.is_windows(): # pragma: no cover
import win32api
@@ -406,6 +407,28 @@ def setup_for_multi_profile(trestle_root: pathlib.Path, big_profile: bool, impor
assert HrefCmd.change_import_href(trestle_root, main_profile_name, new_href, 0) == 0
+def setup_for_inherit(
+ tmp_trestle_dir: pathlib.Path, prof_name: str, output_name: str, ssp_name: str
+) -> argparse.Namespace:
+ """Create the ssp and parent profile for inherit commands."""
+ load_from_json(tmp_trestle_dir, 'simplified_nist_catalog', 'nist_cat', cat.Catalog)
+ if prof_name:
+ load_from_json(tmp_trestle_dir, prof_name, prof_name, prof.Profile)
+ if ssp_name:
+ load_from_json(tmp_trestle_dir, ssp_name, ssp_name, ssp.SystemSecurityPlan)
+
+ args = argparse.Namespace(
+ trestle_root=tmp_trestle_dir,
+ profile=prof_name,
+ output=output_name,
+ ssp=ssp_name,
+ version=None,
+ verbose=0,
+ )
+
+ return args
+
+
def load_from_json(
tmp_trestle_dir: pathlib.Path, file_prefix: str, model_name: str, model_type: OscalBaseModel
) -> None:
diff --git a/tests/trestle/core/commands/author/catalog_test.py b/tests/trestle/core/commands/author/catalog_test.py
index 0130b628e..5c01a8ed2 100644
--- a/tests/trestle/core/commands/author/catalog_test.py
+++ b/tests/trestle/core/commands/author/catalog_test.py
@@ -693,7 +693,7 @@ def test_catalog_duplicate_parts_statement(tmp_trestle_dir: pathlib.Path, monkey
file_utils.insert_text_in_file(md_path, '## Control Statement', control_statement_prose_with_parts)
catalog_assemble = 'trestle author catalog-assemble -o my_catalog -m md_catalog'
- test_utils.execute_command_and_assert(catalog_assemble, 1, monkeypatch)
+ test_utils.execute_command_and_assert(catalog_assemble, 0, monkeypatch)
_, error = capsys.readouterr()
assert 'Duplicate part id ac-2_smt.a' in error
diff --git a/tests/trestle/core/commands/author/command_test.py b/tests/trestle/core/commands/author/command_test.py
index 19ad4f8b1..c45b32a81 100644
--- a/tests/trestle/core/commands/author/command_test.py
+++ b/tests/trestle/core/commands/author/command_test.py
@@ -22,6 +22,7 @@
import pytest
import trestle.cli
+from trestle.core.commands.common.return_codes import CmdReturnCodes
def test_governed_docs_cli(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
@@ -32,7 +33,7 @@ def test_governed_docs_cli(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPat
trestle.cli.run()
# FIXME: Needs to be changed once implemented.
assert wrapped_error.type == SystemExit
- assert wrapped_error.value.code == 2
+ assert wrapped_error.value.code == CmdReturnCodes.INCORRECT_ARGS.value
def test_governed_folders_cli(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
@@ -43,7 +44,7 @@ def test_governed_folders_cli(tmp_trestle_dir: pathlib.Path, monkeypatch: Monkey
trestle.cli.run()
# FIXME: Needs to be changed once implemented.
assert wrapped_error.type == SystemExit
- assert wrapped_error.value.code == 2
+ assert wrapped_error.value.code == CmdReturnCodes.INCORRECT_ARGS.value
@pytest.mark.parametrize(
@@ -52,8 +53,9 @@ def test_governed_folders_cli(tmp_trestle_dir: pathlib.Path, monkeypatch: Monkey
def test_failure_not_trestle(command_string, tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
"""Test for failure based on not in trestle directory."""
monkeypatch.setattr(sys, 'argv', command_string.split())
+ monkeypatch.chdir(tmp_path)
with pytest.raises(SystemExit) as wrapped_error:
trestle.cli.run()
# FIXME: Needs to be changed once implemented.
assert wrapped_error.type == SystemExit
- assert wrapped_error.value.code == 5
+ assert wrapped_error.value.code == CmdReturnCodes.TRESTLE_ROOT_ERROR.value
diff --git a/tests/trestle/core/commands/author/jinja_cmd_test.py b/tests/trestle/core/commands/author/jinja_cmd_test.py
index a88cb74d9..aa4e4e416 100644
--- a/tests/trestle/core/commands/author/jinja_cmd_test.py
+++ b/tests/trestle/core/commands/author/jinja_cmd_test.py
@@ -25,9 +25,14 @@
from trestle.core.markdown.docs_markdown_node import DocsMarkdownNode
-def setup_ssp(testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch):
+def setup_ssp(
+ testdata_dir: pathlib.Path,
+ tmp_trestle_dir: pathlib.Path,
+ monkeypatch: MonkeyPatch,
+ profile_name: str = 'comp_prof'
+):
"""Prepare repository for docs generation."""
- args, _ = setup_for_ssp(tmp_trestle_dir, 'comp_prof', 'my_ssp')
+ args, _ = setup_for_ssp(tmp_trestle_dir, profile_name, 'my_ssp')
ssp_cmd = SSPGenerate()
assert ssp_cmd._run(args) == 0
@@ -151,6 +156,19 @@ def test_jinja_profile_docs(
assert node4.get_node_for_key('## Implementation Guidance')
+def test_jinja_profile_docs_no_part_prose(
+ testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch
+) -> None:
+ """Test Jinja Profile to multiple md files output. Test for if part to add does not have prose at all."""
+ input_template = 'profile_to_docs_no_part_prose.md.jinja'
+ profile_name = 'comp_prof_part_none'
+
+ setup_ssp(testdata_dir, tmp_trestle_dir, monkeypatch, profile_name)
+
+ command_import = f'trestle author jinja -i {input_template} -o controls -p {profile_name} --docs-profile'
+ execute_command_and_assert(command_import, 0, monkeypatch)
+
+
def test_jinja_profile_docs_with_group_title(
testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch
) -> None:
diff --git a/tests/trestle/core/commands/author/profile_test.py b/tests/trestle/core/commands/author/profile_test.py
index 2d9318c53..676719473 100644
--- a/tests/trestle/core/commands/author/profile_test.py
+++ b/tests/trestle/core/commands/author/profile_test.py
@@ -37,7 +37,7 @@
from trestle.common.list_utils import comma_colon_sep_to_dict, comma_sep_to_list
from trestle.common.model_utils import ModelUtils
from trestle.core.catalog.catalog_interface import CatalogInterface
-from trestle.core.commands.author.profile import ProfileAssemble, ProfileGenerate
+from trestle.core.commands.author.profile import ProfileAssemble, ProfileGenerate, ProfileInherit
from trestle.core.control_interface import ControlInterface
from trestle.core.markdown.docs_markdown_node import DocsMarkdownNode
from trestle.core.markdown.markdown_api import MarkdownAPI
@@ -1016,3 +1016,102 @@ def test_profile_resolve_failures(tmp_trestle_dir: pathlib.Path, monkeypatch: Mo
test_utils.execute_command_and_assert(core_command + '-lp prefix', 1, monkeypatch)
test_utils.execute_command_and_assert(core_command + '-vap prefix', 1, monkeypatch)
test_utils.execute_command_and_assert(core_command + '-sl -vap prefix', 1, monkeypatch)
+
+
+def test_profile_inherit(tmp_trestle_dir: pathlib.Path):
+ """Test profile initialization and seeding for various use cases."""
+ output_profile = 'my_profile'
+ excluded = prof.WithId(__root__='ac-1')
+
+ # Test with a profile and ssp that has all controls with exported information
+ args = test_utils.setup_for_inherit(tmp_trestle_dir, 'simple_test_profile', output_profile, 'leveraged_ssp')
+ prof_inherit = ProfileInherit()
+ assert prof_inherit._run(args) == 0
+
+ result_prof, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ output_profile,
+ prof.Profile,
+ FileContentType.JSON
+ )
+
+ assert result_prof.imports[0].href == 'trestle://profiles/simple_test_profile/profile.json'
+ assert len(result_prof.imports[0].include_controls[0].with_ids) == 2
+ assert len(result_prof.imports[0].exclude_controls[0].with_ids) == 1
+ assert result_prof.imports[0].exclude_controls[0].with_ids[0] == excluded
+
+ # Test with a profile that has more controls than the ssp
+ args = test_utils.setup_for_inherit(tmp_trestle_dir, 'simple_test_profile_more', output_profile, 'leveraged_ssp')
+ prof_inherit = ProfileInherit()
+ assert prof_inherit._run(args) == 0
+
+ result_prof, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ output_profile,
+ prof.Profile,
+ FileContentType.JSON
+ )
+
+ assert result_prof.imports[0].href == 'trestle://profiles/simple_test_profile_more/profile.json'
+ assert len(result_prof.imports[0].include_controls[0].with_ids) == 3
+ assert len(result_prof.imports[0].exclude_controls[0].with_ids) == 1
+ assert result_prof.imports[0].exclude_controls[0].with_ids[0] == excluded
+
+ # Test with a profile that has less controls than the ssp
+ args = test_utils.setup_for_inherit(tmp_trestle_dir, 'simple_test_profile_less', output_profile, 'leveraged_ssp')
+ prof_inherit = ProfileInherit()
+ assert prof_inherit._run(args) == 0
+
+ result_prof, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ output_profile,
+ prof.Profile,
+ FileContentType.JSON
+ )
+
+ assert result_prof.imports[0].href == 'trestle://profiles/simple_test_profile_less/profile.json'
+ assert len(result_prof.imports[0].include_controls[0].with_ids) == 1
+ assert len(result_prof.imports[0].exclude_controls[0].with_ids) == 1
+ assert result_prof.imports[0].exclude_controls[0].with_ids[0] == excluded
+
+ # Test with a profile that has all controls filtered out
+ args = test_utils.setup_for_inherit(tmp_trestle_dir, 'simple_test_profile_single', output_profile, 'leveraged_ssp')
+ prof_inherit = ProfileInherit()
+ assert prof_inherit._run(args) == 0
+
+ result_prof, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ output_profile,
+ prof.Profile,
+ FileContentType.JSON
+ )
+
+ assert result_prof.imports[0].href == 'trestle://profiles/simple_test_profile_single/profile.json'
+ assert len(result_prof.imports[0].include_controls[0].with_ids) == 0
+ assert len(result_prof.imports[0].exclude_controls[0].with_ids) == 1
+ assert result_prof.imports[0].exclude_controls[0].with_ids[0] == excluded
+
+ # Test with version set
+ args = test_utils.setup_for_inherit(tmp_trestle_dir, 'simple_test_profile_less', output_profile, 'leveraged_ssp')
+ prof_inherit = ProfileInherit()
+ args.version = '1.0.0'
+ assert prof_inherit._run(args) == 0
+
+ result_prof, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ output_profile,
+ prof.Profile,
+ FileContentType.JSON
+ )
+
+ assert result_prof.metadata.version == '1.0.0'
+
+ # Force a failure with non-existent profile
+ args.profile = 'bad_prof'
+ prof_inherit = ProfileInherit()
+ assert prof_inherit._run(args) == 1
+
+ # Force a failure with a cyclic dependency
+ args.output = args.profile
+ prof_inherit = ProfileInherit()
+ assert prof_inherit._run(args) == 2
diff --git a/tests/trestle/core/commands/author/ssp_test.py b/tests/trestle/core/commands/author/ssp_test.py
index 64d5611b5..34288b135 100644
--- a/tests/trestle/core/commands/author/ssp_test.py
+++ b/tests/trestle/core/commands/author/ssp_test.py
@@ -486,7 +486,8 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
regenerate=False,
version=None,
components=None,
- implementation_status=None
+ implementation_status=None,
+ control_origination=None
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 0
@@ -514,7 +515,8 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
regenerate=True,
version=None,
components='comp_aa',
- implementation_status=None
+ implementation_status=None,
+ control_origination=None
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 0
@@ -536,7 +538,8 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
regenerate=True,
version=None,
components='comp_aa',
- implementation_status=None
+ implementation_status=None,
+ control_origination=None
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 0
@@ -551,7 +554,8 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
regenerate=False,
version=None,
components=None,
- implementation_status='not-applicable,implemented'
+ implementation_status='not-applicable,implemented',
+ control_origination=None
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 0
@@ -578,7 +582,8 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
regenerate=False,
version=None,
components=None,
- implementation_status='not-applicable'
+ implementation_status='not-applicable',
+ control_origination=None
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 0
@@ -602,8 +607,11 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
verbose=0,
regenerate=True,
version=None,
- components=None
+ components=None,
+ implementation_status=None,
+ control_origination=None
)
+
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 1
@@ -618,7 +626,9 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
verbose=0,
regenerate=True,
version=None,
- components=None
+ components=None,
+ implementation_status=None,
+ control_origination=None
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 1
@@ -634,7 +644,105 @@ def test_ssp_filter(tmp_trestle_dir: pathlib.Path) -> None:
regenerate=True,
version=None,
components=None,
- implementation_status=bad_impl
+ implementation_status=bad_impl,
+ control_origination=None
+ )
+ ssp_filter = SSPFilter()
+ assert ssp_filter._run(args) == 1
+
+
+def test_ssp_filter_control_origination(tmp_trestle_dir: pathlib.Path) -> None:
+ """Test the ssp filter when filtering by control origination."""
+ gen_args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name)
+ ssp_gen = SSPGenerate()
+ assert ssp_gen._run(gen_args) == 0
+
+ # create ssp from the markdown
+ ssp_assemble = SSPAssemble()
+ args = argparse.Namespace(
+ trestle_root=tmp_trestle_dir,
+ markdown=ssp_name,
+ output=ssp_name,
+ verbose=0,
+ name=None,
+ version=None,
+ regenerate=False,
+ compdefs=gen_args.compdefs
+ )
+ assert ssp_assemble._run(args) == 0
+
+ ssp: ossp.SystemSecurityPlan
+ ssp, _ = ModelUtils.load_model_for_class(tmp_trestle_dir, ssp_name, ossp.SystemSecurityPlan, FileContentType.JSON)
+
+ assert len(ssp.control_implementation.implemented_requirements) == 8
+
+ filtered_name = 'filtered_ssp'
+
+ # now filter the ssp by multiple control origination values
+ args = argparse.Namespace(
+ trestle_root=tmp_trestle_dir,
+ name=ssp_name,
+ profile=None,
+ output=filtered_name,
+ verbose=0,
+ regenerate=False,
+ version=None,
+ components=None,
+ implementation_status=None,
+ control_origination='customer-configured,system-specific'
+ )
+ ssp_filter = SSPFilter()
+ assert ssp_filter._run(args) == 0
+
+ ssp, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ filtered_name,
+ ossp.SystemSecurityPlan,
+ FileContentType.JSON
+ )
+
+ # confirm the imp_reqs have been culled to two controls
+ assert len(ssp.control_implementation.implemented_requirements) == 2
+
+ # now filter the ssp by a control origination that is unused
+ args = argparse.Namespace(
+ trestle_root=tmp_trestle_dir,
+ name=ssp_name,
+ profile=None,
+ output=filtered_name,
+ verbose=0,
+ regenerate=False,
+ version=None,
+ components=None,
+ implementation_status=None,
+ control_origination='inherited'
+ )
+ ssp_filter = SSPFilter()
+ assert ssp_filter._run(args) == 0
+
+ ssp, _ = ModelUtils.load_model_for_class(
+ tmp_trestle_dir,
+ filtered_name,
+ ossp.SystemSecurityPlan,
+ FileContentType.JSON
+ )
+
+ # confirm the imp_reqs have been culled to zero controls
+ assert len(ssp.control_implementation.implemented_requirements) == 0
+
+ # filter with an invalid control origination to trigger error
+ bad_co = 'co_bad'
+ args = argparse.Namespace(
+ trestle_root=tmp_trestle_dir,
+ name=ssp_name,
+ profile=None,
+ output=filtered_name,
+ verbose=0,
+ regenerate=True,
+ version=None,
+ components=None,
+ implementation_status=None,
+ control_origination=bad_co
)
ssp_filter = SSPFilter()
assert ssp_filter._run(args) == 1
diff --git a/tests/trestle/core/commands/href_test.py b/tests/trestle/core/commands/href_test.py
index 516a12e14..fbd23588d 100644
--- a/tests/trestle/core/commands/href_test.py
+++ b/tests/trestle/core/commands/href_test.py
@@ -24,6 +24,7 @@
from trestle.cli import Trestle
from trestle.common.model_utils import ModelUtils
+from trestle.core.commands.common.return_codes import CmdReturnCodes
from trestle.core.models.file_content_type import FileContentType
from trestle.oscal import profile
@@ -70,6 +71,14 @@ def test_href_failures(
tmp_path: pathlib.Path, keep_cwd: pathlib.Path, simplified_nist_profile: profile.Profile, monkeypatch: MonkeyPatch
) -> None:
"""Test href failure modes."""
+ cmd_string = 'trestle href -n my_test_model -hr foobar'
+
+ # not in trestle project so fail
+ monkeypatch.setattr(sys, 'argv', cmd_string.split())
+ monkeypatch.chdir(tmp_path)
+ rc = Trestle().run()
+ assert rc == CmdReturnCodes.TRESTLE_ROOT_ERROR.value
+
# prepare trestle project dir with the file
models_path, profile_path = test_utils.prepare_trestle_project_dir(
tmp_path,
@@ -77,13 +86,6 @@ def test_href_failures(
simplified_nist_profile,
test_utils.PROFILES_DIR)
- cmd_string = 'trestle href -n my_test_model -hr foobar'
-
- # not in trestle project so fail
- monkeypatch.setattr(sys, 'argv', cmd_string.split())
- rc = Trestle().run()
- assert rc == 5
-
os.chdir(models_path)
cmd_string = 'trestle href -n my_test_model -hr foobar -i 2'
@@ -93,4 +95,4 @@ def test_href_failures(
simplified_nist_profile.oscal_write(profile_path)
monkeypatch.setattr(sys, 'argv', cmd_string.split())
rc = Trestle().run()
- assert rc == 1
+ assert rc == CmdReturnCodes.COMMAND_ERROR.value
diff --git a/tests/trestle/core/commands/remove_test.py b/tests/trestle/core/commands/remove_test.py
index a796220e9..76f744b82 100644
--- a/tests/trestle/core/commands/remove_test.py
+++ b/tests/trestle/core/commands/remove_test.py
@@ -27,6 +27,7 @@
import trestle.common.err as err
from trestle.cli import Trestle
+from trestle.core.commands.common.return_codes import CmdReturnCodes
from trestle.core.commands.remove import RemoveCmd
from trestle.core.models.actions import RemoveAction
from trestle.core.models.elements import Element, ElementPath
@@ -124,20 +125,20 @@ def test_remove_failure(tmp_path: pathlib.Path):
def test_run_failure_switches(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch):
"""Test failure of _run on bad switches for RemoveCmd."""
# 1. Missing --file argument.
- testargs = ['trestle', 'remove', '-e', 'catalog.metadata.roles']
+ testargs = ['trestle', 'remove', '-e', 'catalog.metadata.roles', '--trestle-root', str(tmp_path)]
monkeypatch.setattr(sys, 'argv', testargs)
with pytest.raises(SystemExit) as e:
Trestle().run()
assert e.type == SystemExit
- assert e.value.code == 2
+ assert e.value.code == CmdReturnCodes.INCORRECT_ARGS.value
# 2. Missing --element argument.
- testargs = ['trestle', 'remove', '-f', './catalog.json']
+ testargs = ['trestle', 'remove', '-f', './catalog.json', '--trestle-root', str(tmp_path)]
monkeypatch.setattr(sys, 'argv', testargs)
with pytest.raises(SystemExit) as e:
Trestle().run()
assert e.type == SystemExit
- assert e.value.code == 2
+ assert e.value.code == CmdReturnCodes.INCORRECT_ARGS.value
def test_run_failure_nonexistent_element(
@@ -154,18 +155,27 @@ def test_run_failure_nonexistent_element(
)
# 1. self.remove() fails -- Should happen if wildcard is given, or nonexistent element.
- testargs = ['trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.blah']
+ testargs = ['trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.blah', '--trestle-root', str(tmp_path)]
monkeypatch.setattr(sys, 'argv', testargs)
exitcode = Trestle().run()
- assert exitcode == 5
+ assert exitcode == CmdReturnCodes.COMMAND_ERROR.value
# 2. Corrupt json file
source_file_path = pathlib.Path.joinpath(test_utils.JSON_TEST_DATA_PATH, 'bad_simple.json')
shutil.copyfile(source_file_path, catalog_def_file)
- testargs = ['trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.metadata.roles']
+ testargs = [
+ 'trestle',
+ 'remove',
+ '-f',
+ str(catalog_def_file),
+ '-e',
+ 'catalog.metadata.roles',
+ '--trestle-root',
+ str(tmp_path)
+ ]
monkeypatch.setattr(sys, 'argv', testargs)
exitcode = Trestle().run()
- assert exitcode == 5
+ assert exitcode == CmdReturnCodes.COMMAND_ERROR.value
def test_run_failure_wildcard(tmp_path: pathlib.Path, sample_catalog_minimal: Catalog, monkeypatch: MonkeyPatch):
@@ -178,10 +188,10 @@ def test_run_failure_wildcard(tmp_path: pathlib.Path, sample_catalog_minimal: Ca
sample_catalog_minimal,
test_utils.CATALOGS_DIR
)
- testargs = ['trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.*']
+ testargs = ['trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.*', '--trestle-root', str(tmp_path)]
monkeypatch.setattr(sys, 'argv', testargs)
exitcode = Trestle().run()
- assert exitcode == 5
+ assert exitcode == CmdReturnCodes.COMMAND_ERROR.value
def test_run_failure_required_element(
@@ -198,10 +208,12 @@ def test_run_failure_required_element(
)
# 4. simulate() fails -- Should happen if required element is target for deletion
monkeypatch.chdir(tmp_path)
- testargs = ['trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.metadata']
+ testargs = [
+ 'trestle', 'remove', '-f', str(catalog_def_file), '-e', 'catalog.metadata', '--trestle-root', str(tmp_path)
+ ]
monkeypatch.setattr(sys, 'argv', testargs)
exitcode = Trestle().run()
- assert exitcode == 1
+ assert exitcode == CmdReturnCodes.COMMAND_ERROR.value
def test_run_failure_project_not_found(
@@ -217,10 +229,10 @@ def test_run_failure_project_not_found(
test_utils.CATALOGS_DIR
)
# 5. get_contextual_model_type() fails, i.e., "Trestle project not found"
- testargs = ['trestle', 'remove', '-f', '/dev/null', '-e', 'catalog.metadata']
+ testargs = ['trestle', 'remove', '-f', '/dev/null', '-e', 'catalog.metadata', '--trestle-root', str(tmp_path)]
monkeypatch.setattr(sys, 'argv', testargs)
exitcode = Trestle().run()
- assert exitcode == 5
+ assert exitcode == CmdReturnCodes.COMMAND_ERROR.value
def test_run_failure_filenotfounderror(
@@ -238,11 +250,18 @@ def test_run_failure_filenotfounderror(
# 6. oscal_read fails because file is not found
# Must specify catalogs/ location, not catalogs/my_test_model/.
testargs = [
- 'trestle', 'remove', '-f', re.sub('my_test_model/', '', str(catalog_def_file)), '-e', 'catalog.metadata'
+ 'trestle',
+ 'remove',
+ '-f',
+ re.sub('my_test_model/', '', str(catalog_def_file)),
+ '-e',
+ 'catalog.metadata',
+ '--trestle-root',
+ str(tmp_path)
]
monkeypatch.setattr(sys, 'argv', testargs)
exitcode = Trestle().run()
- assert exitcode == 5
+ assert exitcode == CmdReturnCodes.COMMAND_ERROR.value
def test_run_failure_plan_execute(
diff --git a/tests/trestle/tasks/oscal_catalog_to_csv_test.py b/tests/trestle/tasks/oscal_catalog_to_csv_test.py
new file mode 100644
index 000000000..3e6cffdb6
--- /dev/null
+++ b/tests/trestle/tasks/oscal_catalog_to_csv_test.py
@@ -0,0 +1,266 @@
+# -*- mode:python; coding:utf-8 -*-
+# Copyright (c) 2023 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.
+"""oscal-catalog-to-csv task tests."""
+
+import configparser
+import csv
+import pathlib
+from typing import Dict, List
+
+from _pytest.monkeypatch import MonkeyPatch
+
+from tests import test_utils
+
+import trestle.tasks.oscal_catalog_to_csv as oscal_catalog_to_csv
+from trestle.core.catalog.catalog_interface import CatalogInterface
+from trestle.oscal.common import HowMany
+from trestle.tasks.base_task import TaskOutcome
+
+CONFIG_BY_CONTROL = 'test-oscal-catalog-to-csv-rev-5-by-control.config'
+CONFIG_BY_STATEMENT = 'test-oscal-catalog-to-csv-rev-5-by-statement.config'
+
+CONFIG_LIST = [f'{CONFIG_BY_CONTROL}', f'{CONFIG_BY_STATEMENT}']
+
+
+def monkey_exception():
+ """Monkey exception."""
+ raise RuntimeError('foobar')
+
+
+def monkey_get_dependent_control_ids(self, control_id: str):
+ """Monkey get_dependent_control_ids."""
+ return ['parent', 'parent']
+
+
+def _get_rows(csv_path: pathlib.Path) -> List[List[str]]:
+ """Get rows from csv file."""
+ rows = []
+ with open(csv_path, 'r', newline='') as f:
+ csv_reader = csv.reader(f, delimiter=',', quoting=csv.QUOTE_MINIMAL)
+ for row in csv_reader:
+ rows.append(row)
+ return rows
+
+
+def _validate(config: str, section: Dict[str, str]) -> None:
+ """Validate."""
+ odir = section['output-dir']
+ oname = section['output-name']
+ opth = pathlib.Path(odir) / oname
+ rows = _get_rows(opth)
+ # spot check
+ if config == CONFIG_BY_CONTROL:
+ assert len(rows) == 1190
+ row = rows[0]
+ assert row[0] == 'Control Identifier'
+ assert row[1] == 'Control Title'
+ assert row[2] == 'Control Text'
+ row = rows[1]
+ assert row[0] == 'AC-1'
+ assert row[1] == 'Policy and Procedures'
+ assert row[
+ 2
+ ] == 'a. Develop, document, and disseminate to [Assignment: organization-defined personnel or roles]: 1. [Selection (one or more): organization-level; mission/business process-level; system-level] access control policy that: 2. Procedures to facilitate the implementation of the access control policy and the associated access controls; b. Designate an [Assignment: official] to manage the development, documentation, and dissemination of the access control policy and procedures; and c. Review and update the current access control: 1. Policy [Assignment: frequency] and following [Assignment: events] ; and 2. Procedures [Assignment: frequency] and following [Assignment: events].' # noqa
+ elif config == CONFIG_BY_STATEMENT:
+ assert len(rows) == 1750
+ row = rows[0]
+ assert row[0] == 'Control Identifier'
+ assert row[1] == 'Control Title'
+ assert row[2] == 'Statement Identifier'
+ assert row[3] == 'Statement Text'
+ row = rows[1]
+ assert row[0] == 'AC-1'
+ assert row[1] == 'Policy and Procedures'
+ assert row[2] == 'AC-1(a)'
+ assert row[
+ 3
+ ] == 'a. Develop, document, and disseminate to [Assignment: organization-defined personnel or roles]: 1. [Selection (one or more): organization-level; mission/business process-level; system-level] access control policy that: 2. Procedures to facilitate the implementation of the access control policy and the associated access controls;' # noqa
+
+
+def _test_init(tmp_path: pathlib.Path):
+ """Test init."""
+ test_utils.ensure_trestle_config_dir(tmp_path)
+
+
+def _get_config_section(tmp_path: pathlib.Path, fname: str) -> tuple:
+ """Get config section."""
+ config = configparser.ConfigParser()
+ config_path = pathlib.Path(f'tests/data/tasks/oscal-catalog-to-csv/{fname}')
+ config.read(config_path)
+ section = config['task.oscal-catalog-to-csv']
+ section['output-dir'] = str(tmp_path)
+ return (config, section)
+
+
+def _get_config_section_init(tmp_path: pathlib.Path, fname: str) -> tuple:
+ """Get config section."""
+ _test_init(tmp_path)
+ return _get_config_section(tmp_path, fname)
+
+
+def test_print_info(tmp_path: pathlib.Path) -> None:
+ """Test print_info."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.print_info()
+ assert retval is None
+
+
+def test_missing_section(tmp_path: pathlib.Path):
+ """Test missing section."""
+ section = None
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
+
+
+def test_missing_input(tmp_path: pathlib.Path):
+ """Test missing input."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section.pop('input-file')
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
+
+
+def test_missing_output(tmp_path: pathlib.Path):
+ """Test missing output."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section.pop('output-dir')
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
+
+
+def test_bogus_level(tmp_path: pathlib.Path):
+ """Test bogus level."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section['level'] = 'foobar'
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
+
+
+def test_simulate(tmp_path: pathlib.Path):
+ """Test execute."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section['output-dir'] = str(tmp_path)
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.simulate()
+ assert retval == TaskOutcome.SIM_SUCCESS
+
+
+def test_execute(tmp_path: pathlib.Path):
+ """Test execute."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section['output-dir'] = str(tmp_path)
+ section['output-name'] = f'{config}.csv'
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.SUCCESS
+ _validate(config, section)
+
+
+def test_no_overwrite(tmp_path: pathlib.Path):
+ """Test no overwrite."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section['output-dir'] = str(tmp_path)
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.SUCCESS
+ section['output-overwrite'] = 'false'
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
+
+
+def test_exception(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch):
+ """Test exception."""
+ monkeypatch.setattr(oscal_catalog_to_csv.CsvHelper, 'write', monkey_exception)
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section['output-dir'] = str(tmp_path)
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
+
+
+def test_join():
+ """Test join."""
+ oscal_catalog_to_csv.join_str(None, None)
+ oscal_catalog_to_csv.join_str('x', None)
+ oscal_catalog_to_csv.join_str(None, 'y')
+ oscal_catalog_to_csv.join_str('x', 'y')
+
+
+def test_derive_id(tmp_path: pathlib.Path):
+ """Test derive_id."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ ifile = section['input-file']
+ ipth = pathlib.Path(ifile)
+ catalog_helper = oscal_catalog_to_csv.CatalogHelper(ipth)
+ ids = ['ac-4.1_smt', 'ac-4.1_smt.a', 'ac-4.1_smt.a.b', 'ac-4.1_smt.a.b.c', 'ac-4.1_smt.a.b.c.d']
+ for id_ in ids:
+ catalog_helper._derive_id(id_)
+
+
+def test_unresolved_param(tmp_path: pathlib.Path):
+ """Test unresolved param."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ ifile = section['input-file']
+ ipth = pathlib.Path(ifile)
+ catalog_helper = oscal_catalog_to_csv.CatalogHelper(ipth)
+ for control in catalog_helper.get_controls():
+ utext = 'foo {{ insert: param, xx-11_odp.02 }} bar'
+ try:
+ catalog_helper._resolve_parms(control, utext)
+ raise RuntimeError('huh?')
+ except RuntimeError:
+ break
+
+
+def test_one_choice(tmp_path: pathlib.Path):
+ """Test one choice."""
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ ifile = section['input-file']
+ ipth = pathlib.Path(ifile)
+ catalog_helper = oscal_catalog_to_csv.CatalogHelper(ipth)
+ for control in catalog_helper.get_controls():
+ if control.params:
+ for param in control.params:
+ if param.select:
+ param.select.how_many = HowMany.one
+ catalog_helper._get_parm_value(control, param.id)
+ return
+
+
+def test_duplicate(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch):
+ """Test duplicate."""
+ monkeypatch.setattr(CatalogInterface, 'get_dependent_control_ids', monkey_get_dependent_control_ids)
+ for config in CONFIG_LIST:
+ _, section = _get_config_section_init(tmp_path, config)
+ section['output-dir'] = str(tmp_path)
+ tgt = oscal_catalog_to_csv.OscalCatalogToCsv(section)
+ retval = tgt.execute()
+ assert retval == TaskOutcome.FAILURE
diff --git a/trestle/common/const.py b/trestle/common/const.py
index 1f9f985cc..e8f224f6d 100644
--- a/trestle/common/const.py
+++ b/trestle/common/const.py
@@ -555,3 +555,15 @@
CONTROL_IMPLEMENTATION = 'control-implementation'
IMPLEMENTED_REQUIREMENT = 'implemented-requirement'
+
+# Following 5 are allowed control origination values for
+# SSP -> ControlImplementation -> ImplementedRequirements -> prop[@name='control-origination']/@value
+ORIGINATION_ORGANIZATION = 'organization'
+
+ORIGINATION_SYSTEM_SPECIFIC = 'system-specific'
+
+ORIGINATION_CUSTOMER_CONFIGURED = 'customer-configured'
+
+ORIGINATION_CUSTOMER_PROVIDED = 'customer-provided'
+
+ORIGINATION_INHERITED = 'inherited'
diff --git a/trestle/core/commands/author/command.py b/trestle/core/commands/author/command.py
index 0c602f8eb..240e00bcf 100644
--- a/trestle/core/commands/author/command.py
+++ b/trestle/core/commands/author/command.py
@@ -26,7 +26,7 @@
from trestle.core.commands.author.folders import Folders
from trestle.core.commands.author.headers import Headers
from trestle.core.commands.author.jinja import JinjaCmd
-from trestle.core.commands.author.profile import ProfileAssemble, ProfileGenerate, ProfileResolve
+from trestle.core.commands.author.profile import ProfileAssemble, ProfileGenerate, ProfileInherit, ProfileResolve
from trestle.core.commands.author.ssp import SSPAssemble, SSPFilter, SSPGenerate
from trestle.core.commands.command_docs import CommandPlusDocs
@@ -49,6 +49,7 @@ class AuthorCmd(CommandPlusDocs):
JinjaCmd,
ProfileAssemble,
ProfileGenerate,
+ ProfileInherit,
ProfileResolve,
SSPAssemble,
SSPFilter,
diff --git a/trestle/core/commands/author/profile.py b/trestle/core/commands/author/profile.py
index 82194cef9..17b2494c6 100644
--- a/trestle/core/commands/author/profile.py
+++ b/trestle/core/commands/author/profile.py
@@ -14,17 +14,20 @@
"""Author commands to generate profile as markdown and assemble to json after edit."""
import argparse
+import copy
import logging
import pathlib
import shutil
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Set
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
import trestle.common.const as const
import trestle.common.log as log
+import trestle.core.generators as gens
import trestle.oscal.profile as prof
+import trestle.oscal.ssp as ssp
from trestle.common import file_utils
from trestle.common.err import TrestleError, TrestleNotFoundError, handle_generic_command_exception
from trestle.common.list_utils import as_filtered_list, as_list, comma_sep_to_list, comma_colon_sep_to_dict, deep_set, none_if_empty # noqa E501
@@ -536,3 +539,199 @@ def resolve_profile(
ModelUtils.save_top_level_model(catalog, trestle_root, catalog_name, FileContentType.JSON)
return CmdReturnCodes.SUCCESS.value
+
+
+class ProfileInherit(AuthorCommonCommand):
+ """Generate and populate profile in JSON from a parent profile and leveraged ssp in the trestle workspace."""
+
+ name = 'profile-inherit'
+
+ def _init_arguments(self) -> None:
+ ssp_help_str = 'Name of the leveraged ssp model in the trestle workspace'
+ self.add_argument('-s', '--ssp', help=ssp_help_str, required=True, type=str)
+ profile_help_str = 'Name of the parent profile model in the trestle workspace'
+ self.add_argument('-p', '--profile', help=profile_help_str, required=True, type=str)
+ output_help_str = 'Name of the output generated json Profile'
+ self.add_argument('-o', '--output', help=output_help_str, required=True, type=str)
+ self.add_argument('-vn', '--version', help=const.HELP_VERSION, required=False, type=str)
+
+ def _run(self, args: argparse.Namespace) -> int:
+ try:
+ log.set_log_level_from_args(args)
+ trestle_root: pathlib.Path = args.trestle_root
+
+ if args.profile:
+ if args.profile == args.output:
+ logger.warning(f'Output profile {args.output} cannot equal parent')
+ return CmdReturnCodes.INCORRECT_ARGS.value
+
+ return self.initialize_profile(
+ trestle_root=trestle_root,
+ parent_prof_name=args.profile,
+ output_prof_name=args.output,
+ leveraged_ssp_name=args.ssp,
+ version=args.version
+ )
+ except Exception as e: # pragma: no cover
+ return handle_generic_command_exception(e, logger, 'Profile generation failed')
+
+ @staticmethod
+ def _is_inherited(all_comps: List[ssp.ByComponent]) -> bool:
+ # Fail fast by checking for any non-compliant components.
+ # Must contain provided export statements, no responsibility
+ # statements, and be implemented.
+ for comp in all_comps:
+ if comp.export is None:
+ return False
+
+ if comp.export.responsibilities is not None:
+ return False
+
+ if comp.export.provided is None:
+ return False
+
+ if comp.implementation_status.state != const.STATUS_IMPLEMENTED:
+ return False
+
+ return True
+
+ @staticmethod
+ def update_profile_import(
+ orig_prof_import: prof.Import, leveraged_ssp: ssp.SystemSecurityPlan, catalog_api: CatalogAPI
+ ) -> None:
+ """Add controls to different sections of a profile import based on catalog and leveraged SSP.
+
+ Args:
+ orig_prof_import: The original profile import that will have the control selection updated.
+ leveraged_ssp: SSP input for control filtering
+ catalog_api: Catalog API with access to controls that need to be filtered
+
+ Returns:
+ None
+ """
+ exclude_with_ids: Set[prof.withId] = set()
+ components_by_id: Dict(str, List[ssp.ByComponent]) = {}
+
+ # Create dictionary containing all by-components by control for faster searching
+ for implemented_requirement in leveraged_ssp.control_implementation.implemented_requirements:
+ by_components: List[ssp.ByComponent] = []
+
+ if implemented_requirement.by_components:
+ by_components.extend(implemented_requirement.by_components)
+ if implemented_requirement.statements:
+ for stm in implemented_requirement.statements:
+ if stm.by_components:
+ by_components.extend(stm.by_components)
+ components_by_id[implemented_requirement.control_id] = none_if_empty(by_components)
+
+ # Looping by controls in the catalog because the ids in the profile should
+ # be a subset of the catalog and not the ssp controls.
+ catalog_control_ids: Set[str] = set(catalog_api._catalog_interface.get_control_ids())
+ for control_id in catalog_control_ids:
+
+ if control_id not in components_by_id:
+ continue
+
+ by_comps: Optional[List[ssp.ByComponent]] = components_by_id[control_id]
+ if by_comps is not None and ProfileInherit._is_inherited(by_comps):
+ exclude_with_ids.add(control_id)
+
+ include_with_ids: Set[prof.withId] = catalog_control_ids - exclude_with_ids
+
+ orig_prof_import.include_controls = [prof.SelectControlById(with_ids=sorted(include_with_ids))]
+ orig_prof_import.exclude_controls = [prof.SelectControlById(with_ids=sorted(exclude_with_ids))]
+
+ def initialize_profile(
+ self,
+ trestle_root: pathlib.Path,
+ parent_prof_name: str,
+ output_prof_name: str,
+ leveraged_ssp_name: str,
+ version: Optional[str],
+ ) -> int:
+ """Initialize profile with controls from a parent profile, filtering by inherited controls.
+
+ Args:
+ trestle_root: Root directory of the trestle workspace
+ parent_prof_name: Name of the parent profile in the trestle workspace
+ output_prof_name: Name of the output profile json file
+ leveraged_ssp_name: Name of the ssp in the trestle workspace for control filtering
+ version: Optional profile version
+
+ Returns:
+ 0 on success, 1 on error
+
+ Notes:
+ The profile model will either be updated or a new json profile created. This will overwrite
+ any import information on an exiting profile, but will preserve control modifications and parameters.
+ Allowing profile updates ensure that SSP export updates can be incorporated into an existing profile. All
+ controls from the original profile will exists and will be grouped by included and excluded controls based
+ on inheritance information.
+ """
+ try:
+ result_profile: prof.Profile
+ existing_profile: Optional[prof.Profile] = None
+
+ existing_profile_path = ModelUtils.get_model_path_for_name_and_class(
+ trestle_root, output_prof_name, prof.Profile
+ )
+
+ # If a profile exists at the output path, use that as a starting point for a new profile.
+ # else create a new sample profile.
+ if existing_profile_path is not None:
+ existing_profile, _ = load_validate_model_name(trestle_root,
+ output_prof_name,
+ prof.Profile,
+ FileContentType.JSON)
+ result_profile = copy.deepcopy(existing_profile)
+ else:
+ result_profile = gens.generate_sample_model(prof.Profile)
+
+ parent_prof_path = ModelUtils.get_model_path_for_name_and_class(
+ trestle_root, parent_prof_name, prof.Profile
+ )
+ if parent_prof_path is None:
+ raise TrestleNotFoundError(
+ f'Profile {parent_prof_name} does not exist. An existing profile must be provided.'
+ )
+
+ local_path = f'profiles/{parent_prof_name}/profile.json'
+ profile_import: prof.Import = gens.generate_sample_model(prof.Import)
+ profile_import.href = const.TRESTLE_HREF_HEADING + local_path
+
+ leveraged_ssp: ssp.SystemSecurityPlan
+ try:
+ leveraged_ssp, _ = load_validate_model_name(
+ trestle_root,
+ leveraged_ssp_name,
+ ssp.SystemSecurityPlan,
+ FileContentType.JSON
+ )
+ except TrestleNotFoundError as e:
+ raise TrestleError(f'SSP {leveraged_ssp_name} not found: {e}')
+
+ prof_resolver = ProfileResolver()
+ catalog = prof_resolver.get_resolved_profile_catalog(
+ trestle_root, parent_prof_path, show_value_warnings=True
+ )
+ catalog_api = CatalogAPI(catalog=catalog)
+
+ # Sort controls based on what controls in the SSP have exported provided information with no
+ # customer responsibility
+ ProfileInherit.update_profile_import(profile_import, leveraged_ssp, catalog_api)
+
+ result_profile.imports[0] = profile_import
+
+ if version:
+ result_profile.metadata.version = version
+
+ if ModelUtils.models_are_equivalent(existing_profile, result_profile): # type: ignore
+ logger.info('Profile is no different from existing version, so no update.')
+ return CmdReturnCodes.SUCCESS.value
+
+ ModelUtils.update_last_modified(result_profile) # type: ignore
+ ModelUtils.save_top_level_model(result_profile, trestle_root, output_prof_name, FileContentType.JSON)
+
+ except TrestleError as e:
+ raise TrestleError(f'Error initializing profile {output_prof_name}: {e}')
+ return CmdReturnCodes.SUCCESS.value
diff --git a/trestle/core/commands/author/ssp.py b/trestle/core/commands/author/ssp.py
index b6e15eb48..45bb30e64 100644
--- a/trestle/core/commands/author/ssp.py
+++ b/trestle/core/commands/author/ssp.py
@@ -589,7 +589,7 @@ class SSPFilter(AuthorCommonCommand):
Filter the controls in an ssp.
The filtered ssp is based on controls included by the following:
- profile, components, and/or implementation status.
+ profile, components, implementation status, and/or control origination.
"""
name = 'ssp-filter'
@@ -607,6 +607,8 @@ def _init_arguments(self) -> None:
self.add_argument('-c', '--components', help=comp_help_str, required=False, type=str)
is_help_str = 'Comma-delimited list of control implementation statuses to include in filtered ssp.'
self.add_argument('-is', '--implementation-status', help=is_help_str, required=False, type=str)
+ co_help_str = 'Comma-delimited list of control origination values to include in filtered ssp.'
+ self.add_argument('-co', '--control-origination', help=co_help_str, required=False, type=str)
def _run(self, args: argparse.Namespace) -> int:
try:
@@ -614,11 +616,12 @@ def _run(self, args: argparse.Namespace) -> int:
trestle_root = pathlib.Path(args.trestle_root)
comp_names: Optional[List[str]] = None
impl_status_values: Optional[List[str]] = None
+ co_values: Optional[List[str]] = None
- if not (args.components or args.implementation_status or args.profile):
+ if not (args.components or args.implementation_status or args.profile or args.control_origination):
logger.warning(
'You must specify at least one, or a combination of: profile, list of component names'
- ', or list of implementation statuses for ssp-filter.'
+ ', list of implementation statuses, or list of control origination values for ssp-filter.'
)
return CmdReturnCodes.COMMAND_ERROR.value
@@ -643,6 +646,24 @@ def _run(self, args: argparse.Namespace) -> int:
)
return CmdReturnCodes.COMMAND_ERROR.value
+ if args.control_origination:
+ co_values = args.control_origination.split(',')
+ allowed_co_values = {
+ const.ORIGINATION_ORGANIZATION,
+ const.ORIGINATION_SYSTEM_SPECIFIC,
+ const.ORIGINATION_INHERITED,
+ const.ORIGINATION_CUSTOMER_CONFIGURED,
+ const.ORIGINATION_CUSTOMER_PROVIDED
+ }
+ allowed_co_string = ', '.join(str(item) for item in allowed_co_values)
+ for co in co_values:
+ if co not in allowed_co_values:
+ logger.warning(
+ f'Provided control origination "{co}" is invalid.\n'
+ f'Please use the following for ssp-filter: {allowed_co_string}'
+ )
+ return CmdReturnCodes.COMMAND_ERROR.value
+
return self.filter_ssp(
trestle_root,
args.name,
@@ -651,7 +672,8 @@ def _run(self, args: argparse.Namespace) -> int:
args.regenerate,
args.version,
comp_names,
- impl_status_values
+ impl_status_values,
+ co_values
)
except Exception as e: # pragma: no cover
return handle_generic_command_exception(e, logger, 'Error generating the filtered ssp')
@@ -665,13 +687,14 @@ def filter_ssp(
regenerate: bool,
version: Optional[str],
components: Optional[List[str]] = None,
- implementation_status: Optional[List[str]] = None
+ implementation_status: Optional[List[str]] = None,
+ control_origination: Optional[List[str]] = None
) -> int:
"""
Filter the ssp and output new ssp.
The filtered ssp is based on controls included by the following:
- profile, components, and/or implementation status.
+ profile, components, implementation status, and/or control origination.
Args:
trestle_root: root directory of the trestle workspace
@@ -682,6 +705,7 @@ def filter_ssp(
version: new version for the model
components: optional list of component names used for filtering
implementation_status: optional list of implementation statuses for filtering
+ control_origination: optional list of control origination values for filtering
Returns:
0 on success, 1 otherwise
@@ -798,6 +822,22 @@ def filter_ssp(
ssp.control_implementation.implemented_requirements = new_imp_reqs
+ # filter implemented requirements by control origination property.
+ # this will remove any implemented requirements without the control origination
+ # property set
+ if control_origination:
+ new_imp_reqs: List[ossp.ImplementedRequirement] = []
+
+ for imp_requirement in ssp.control_implementation.implemented_requirements:
+ if imp_requirement.props:
+ for prop in imp_requirement.props:
+ if prop.name == const.CONTROL_ORIGINATION and prop.value in control_origination:
+ new_imp_reqs.append(imp_requirement)
+ # only add the imp requirement one time
+ break
+
+ ssp.control_implementation.implemented_requirements = new_imp_reqs
+
if version:
ssp.metadata.version = version
diff --git a/trestle/core/docs_control_writer.py b/trestle/core/docs_control_writer.py
index 183014040..927d55cc0 100644
--- a/trestle/core/docs_control_writer.py
+++ b/trestle/core/docs_control_writer.py
@@ -217,7 +217,8 @@ def _write_part_info(
if tag_pattern:
self._md_file.new_line(tag_pattern.replace('[.]', tag_section_name))
self._md_file.new_paragraph()
- self._md_file.new_line(part_info.prose)
+ prose = '' if part_info.prose is None else part_info.prose
+ self._md_file.new_line(prose)
self._md_file.new_paragraph()
for subpart_info in as_list(part_info.parts):
diff --git a/trestle/core/markdown/control_markdown_node.py b/trestle/core/markdown/control_markdown_node.py
index 7777a9280..9b967ebb6 100644
--- a/trestle/core/markdown/control_markdown_node.py
+++ b/trestle/core/markdown/control_markdown_node.py
@@ -463,7 +463,7 @@ def _read_parts(self, indent: int, ii: int, lines: List[str], parent_id: str,
prop = common.Property(name='label', value=id_text)
part = common.Part(name=name, id=id_, prose=prose, props=[prop])
if id_ in [p.id for p in parts]:
- raise TrestleError(
+ logger.warning(
f'Duplicate part id {id_} is found in markdown '
f'{tree_context.control_id}. Please correct the part label in line {line}.'
)
diff --git a/trestle/tasks/cis_xlsx_to_oscal_catalog.py b/trestle/tasks/cis_xlsx_to_oscal_catalog.py
index 88ee87087..bd09cc4b3 100644
--- a/trestle/tasks/cis_xlsx_to_oscal_catalog.py
+++ b/trestle/tasks/cis_xlsx_to_oscal_catalog.py
@@ -225,7 +225,7 @@ class CisXlsxToOscalCatalog(TaskBase):
def __init__(self, config_object: Optional[configparser.SectionProxy]) -> None:
"""
- Initialize trestle task ocp4-cis-profile-to-oscal-catalog.
+ Initialize trestle task.
Args:
config_object: Config section associated with the task.
diff --git a/trestle/tasks/oscal_catalog_to_csv.py b/trestle/tasks/oscal_catalog_to_csv.py
new file mode 100644
index 000000000..b68ed8457
--- /dev/null
+++ b/trestle/tasks/oscal_catalog_to_csv.py
@@ -0,0 +1,485 @@
+# -*- mode:python; coding:utf-8 -*-
+# Copyright (c) 2023 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.
+"""OSCAL transformation tasks."""
+
+# mypy: ignore-errors # noqa E800
+import configparser
+import copy
+import csv
+import datetime
+import logging
+import pathlib
+import re
+import traceback
+from typing import Iterator, List, Optional
+
+from trestle.core.catalog.catalog_interface import CatalogInterface
+from trestle.oscal.catalog import Catalog
+from trestle.oscal.catalog import Control
+from trestle.oscal.common import HowMany
+from trestle.oscal.common import Link
+from trestle.oscal.common import Parameter
+from trestle.oscal.common import Part
+from trestle.tasks.base_task import TaskBase
+from trestle.tasks.base_task import TaskOutcome
+
+logger = logging.getLogger(__name__)
+
+timestamp = datetime.datetime.utcnow().replace(microsecond=0).replace(tzinfo=datetime.timezone.utc).isoformat()
+
+recurse = True
+
+level_control = 'control'
+level_statement = 'statement'
+level_default = level_statement
+level_list = [level_control, level_statement]
+
+
+def join_str(s1: Optional[str], s2: Optional[str], sep: str = ' ') -> Optional[str]:
+ """Join strings."""
+ if s1 is None:
+ rval = s2
+ elif s2 is None:
+ rval = s1
+ else:
+ rval = f'{s1}{sep}{s2}'
+ return rval
+
+
+def convert_control_id(control_id: str) -> str:
+ """Convert control id."""
+ rval = copy.copy(control_id)
+ rval = rval.upper()
+ if '.' in rval:
+ rval = rval.replace('.', '(')
+ rval = rval + ')'
+ return rval
+
+
+def convert_smt_id(smt_id: str) -> str:
+ """Convert smt id."""
+ parts = smt_id.split('_smt')
+ seg1 = convert_control_id(parts[0])
+ seg2 = ''
+ if len(parts) == 2:
+ seg2 = parts[1]
+ if '.' in seg2:
+ seg2 = seg2.replace('.', '(')
+ seg2 = seg2 + ')'
+ rval = f'{seg1}{seg2}'
+ return rval
+
+
+class CsvHelper:
+ """Csv Helper."""
+
+ def __init__(self, path) -> None:
+ """Initialize."""
+ self.path = path
+
+ def write(self, rows: List[List[str]]) -> None:
+ """Write csv file."""
+ with open(self.path, 'w', newline='', encoding='utf-8') as output:
+ csv_writer = csv.writer(output, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
+ for row in rows:
+ csv_writer.writerow(row)
+
+
+class CatalogHelper:
+ """OSCAL Catalog Helper."""
+
+ def __init__(self, path) -> None:
+ """Initialize."""
+ self.path = path
+ self.catalog = Catalog.oscal_read(path)
+ self.catalog_interface = CatalogInterface(self.catalog)
+ self._init_control_parent_map()
+
+ def _init_control_parent_map(self, recurse=True) -> None:
+ """Initialize map: Child Control.id to parent Control."""
+ self._control_parent_map = {}
+ for control in self.catalog_interface.get_all_controls_from_catalog(recurse):
+ parents = self.catalog_interface.get_dependent_control_ids(control.id)
+ for parent in parents:
+ # assert child has only one parent
+ if parent in self._control_parent_map.keys():
+ raise RuntimeError('{parent} duplicate?')
+ self._control_parent_map[parent] = control
+
+ def get_parent_control(self, ctl_id: str) -> Control:
+ """Return parent Control of child Control.id, if any."""
+ return self._control_parent_map.get(ctl_id)
+
+ def get_family_controls(self, ctl_id: str) -> List[Control]:
+ """Return family of controls for Control.id, if any."""
+ rval = []
+ search_id = ctl_id.split('.')[0]
+ for control in self.catalog_interface.get_all_controls_from_catalog(recurse):
+ if control.id.startswith(search_id):
+ rval.append(control)
+ return rval
+
+ def get_controls(self, recurse=True) -> Iterator:
+ """Return controls iterator."""
+ for control in self.catalog_interface.get_all_controls_from_catalog(recurse):
+ yield control
+
+ def get_statement_text_for_control(self, control: Control) -> Optional[str]:
+ """Get statement text for control."""
+ statement_text = self._withdrawn(control)
+ return statement_text
+
+ def get_statement_text_for_part(self, control: Control, part: Part) -> Optional[str]:
+ """Get statement text for part."""
+ statement_text = self._derive_text(control, part)
+ if part.parts:
+ for subpart in part.parts:
+ if '_smt' in subpart.id:
+ partial_text = self._derive_text(control, subpart)
+ statement_text = join_str(statement_text, partial_text)
+ return statement_text
+
+ def _withdrawn(self, control: Control) -> Optional[str]:
+ """Check if withdrawn."""
+ rval = None
+ for prop in control.props:
+ if prop.name.lower() == 'status' and prop.value.lower() == 'withdrawn':
+ status = self._get_status(control)
+ rval = join_str('Withdrawn', status, '')
+ rval = f'[{rval}]'
+ break
+ return rval
+
+ def _link_generator(self, control: Control) -> Iterator[Link]:
+ """Link generator."""
+ if control.links:
+ for link in control.links:
+ yield link
+
+ def _get_status(self, control: Control) -> Optional[str]:
+ """Get status."""
+ rval = None
+ ilist = None
+ for link in self._link_generator(control):
+ if link.rel.lower() == 'moved-to':
+ moved = self._href_to_control(link.href)
+ rval = f': Moved to {moved}.'
+ break
+ if link.rel.lower() == 'incorporated-into':
+ incorporated = self._href_to_control(link.href)
+ if ilist is None:
+ ilist = f'{incorporated}'
+ else:
+ ilist = f'{ilist}, {incorporated}'
+ if ilist:
+ rval = f': Incorporated into {ilist}.'
+ return rval
+
+ def _href_to_control(self, href: str) -> str:
+ """Convert href to control."""
+ rval = href.replace('#', '').upper()
+ return rval
+
+ def _derive_text(self, control: Control, part: Part) -> Optional[str]:
+ """Derive control text."""
+ rval = None
+ if part.prose:
+ id_ = self._derive_id(part.id)
+ text = self._resolve_parms(control, part.prose)
+ rval = join_str(id_, text)
+ return rval
+
+ def _derive_id(self, id_: str) -> str:
+ """Derive control text sub-part id."""
+ rval = None
+ id_parts = id_.split('_smt')
+ if id_parts[1]:
+ id_sub_parts = id_parts[1].split('.')
+ if len(id_sub_parts) == 2:
+ rval = f'{id_sub_parts[1]}.'
+ elif len(id_sub_parts) == 3:
+ rval = f'{id_sub_parts[2]}.'
+ elif len(id_sub_parts) == 4:
+ rval = f'({id_sub_parts[3]})'
+ return rval
+
+ def _resolve_parms(self, control: Control, utext: str) -> str:
+ """Resolve parm."""
+ rtext = self._resolve_parms_for_control(control, utext)
+ if '{{' in rtext:
+ parent_control = self.get_parent_control(control.id)
+ if parent_control:
+ rtext = self._resolve_parms_for_control(parent_control, rtext)
+ if '{{' in rtext:
+ family_controls = self.get_family_controls(control.id)
+ for family_control in family_controls:
+ rtext = self._resolve_parms_for_control(family_control, rtext)
+ if '{{' in rtext:
+ text = f'control.id: {control.id} unresolved: {rtext}'
+ raise RuntimeError(text)
+ return rtext
+
+ def _resolve_parms_for_control(self, control: Control, utext: str) -> str:
+ """Resolve parms for control."""
+ rtext = utext
+ staches: List[str] = re.findall(r'{{.*?}}', utext)
+ if staches:
+ for stach in staches:
+ parm_id = stach
+ parm_id = parm_id.replace('{{', '')
+ parm_id = parm_id.replace('}}', '')
+ parm_id = parm_id.split(',')[1].strip()
+ value = self._get_parm_value(control, parm_id)
+ if value:
+ rtext = rtext.replace(stach, value)
+ return rtext
+
+ def _get_parm_value(self, control: Control, parm_id: str) -> str:
+ """Get parm value."""
+ rval = None
+ if control.params:
+ for param in control.params:
+ if param.id != parm_id:
+ continue
+ if param.label:
+ rval = f'[Assignment: {param.label}]'
+ elif param.select:
+ choices = self._get_parm_choices(control, param)
+ if param.select.how_many == HowMany.one:
+ rval = f'[Selection (one): {choices}]'
+ else:
+ rval = f'[Selection (one or more): {choices}]'
+ break
+ return rval
+
+ def _get_parm_choices(self, control: Control, param: Parameter) -> str:
+ """Get parm choices."""
+ choices = ''
+ for choice in param.select.choice:
+ rchoice = self._resolve_parms(control, choice)
+ if choices:
+ choices += f'; {rchoice}'
+ else:
+ choices += f'{rchoice}'
+ return choices
+
+
+class ContentManager():
+ """Content manager."""
+
+ def __init__(self, catalog_helper: CatalogHelper) -> None:
+ """Initialize."""
+ self.catalog_helper = catalog_helper
+ self.rows = []
+ self.row_template = None
+
+ def add(self, row: List):
+ """Add row."""
+ n_row = copy.copy(row)
+ t_row = self.row_template
+ if t_row:
+ for index in range(3):
+ if n_row[index] == t_row[index]:
+ n_row[index] = None
+ self.rows.append(n_row)
+ self.row_template = row
+
+ def get_content(self, level: str) -> List:
+ """Get content."""
+ if level == level_control:
+ rval = self._get_content_by_control()
+ else:
+ rval = self._get_content_by_statement()
+ return rval
+
+ def _get_content_by_statement(self) -> List:
+ """Get content by statement."""
+ catalog_helper = self.catalog_helper
+ header = ['Control Identifier', 'Control Title', 'Statement Identifier', 'Statement Text']
+ self.rows.append(header)
+ for control in catalog_helper.get_controls():
+ control_id = convert_control_id(control.id)
+ if control.parts:
+ self._add_parts_by_statement(control)
+ else:
+ statement_text = catalog_helper.get_statement_text_for_control(control)
+ row = [control_id, control.title, '', statement_text]
+ self.add(row)
+ return self.rows
+
+ def _add_subparts_by_statement(self, control: Control, part: Part) -> None:
+ """Add subparts by statement."""
+ catalog_helper = self.catalog_helper
+ control_id = convert_control_id(control.id)
+ for subpart in part.parts:
+ if '_smt' in subpart.id:
+ statement_text = catalog_helper.get_statement_text_for_part(control, subpart)
+ row = [control_id, control.title, convert_smt_id(subpart.id), statement_text]
+ self.add(row)
+
+ def _add_parts_by_statement(self, control: Control) -> None:
+ """Add parts by statement."""
+ catalog_helper = self.catalog_helper
+ control_id = convert_control_id(control.id)
+ for part in control.parts:
+ if part.id:
+ if '_smt' not in part.id:
+ continue
+ if part.parts:
+ self._add_subparts_by_statement(control, part)
+ else:
+ statement_text = catalog_helper.get_statement_text_for_part(control, part)
+ row = [control_id, control.title, convert_smt_id(part.id), statement_text]
+ self.add(row)
+
+ def _get_content_by_control(self) -> List:
+ """Get content by statement."""
+ catalog_helper = self.catalog_helper
+ header = ['Control Identifier', 'Control Title', 'Control Text']
+ self.rows.append(header)
+ for control in catalog_helper.get_controls():
+ control_id = convert_control_id(control.id)
+ if control.parts:
+ self._add_parts_by_control(control)
+ else:
+ control_text = catalog_helper.get_statement_text_for_control(control)
+ row = [control_id, control.title, control_text]
+ self.add(row)
+ return self.rows
+
+ def _add_subparts_by_control(self, control: Control, part: Part, control_text) -> str:
+ """Add subparts by control."""
+ catalog_helper = self.catalog_helper
+ for subpart in part.parts:
+ if '_smt' in subpart.id:
+ statement_text = catalog_helper.get_statement_text_for_part(control, subpart)
+ control_text = join_str(control_text, statement_text)
+ return control_text
+
+ def _add_parts_by_control(self, control: Control) -> None:
+ """Add parts by control."""
+ catalog_helper = self.catalog_helper
+ control_id = convert_control_id(control.id)
+ control_text = None
+ for part in control.parts:
+ if part.id:
+ if '_smt' not in part.id:
+ continue
+ if part.parts:
+ control_text = self._add_subparts_by_control(control, part, control_text)
+ else:
+ statement_text = catalog_helper.get_statement_text_for_part(control, part)
+ control_text = join_str(control_text, statement_text)
+ row = [control_id, control.title, control_text]
+ self.add(row)
+
+
+class OscalCatalogToCsv(TaskBase):
+ """
+ Task to transform OSCAL catalog to .csv.
+
+ Attributes:
+ name: Name of the task.
+ """
+
+ name = 'oscal-catalog-to-csv'
+
+ def __init__(self, config_object: Optional[configparser.SectionProxy]) -> None:
+ """
+ Initialize trestle task.
+
+ Args:
+ config_object: Config section associated with the task.
+ """
+ super().__init__(config_object)
+
+ def print_info(self) -> None:
+ """Print the help string."""
+ logger.info(f'Help information for {self.name} task.')
+ logger.info('')
+ logger.info('Purpose: Create .csv from OSCAL catalog.')
+ logger.info('')
+ logger.info('Configuration flags sit under [task.oscal-catalog-to-csv]:')
+ text1 = ' input-file = '
+ text2 = '(required) path of file to read the catalog.'
+ logger.info(text1 + text2)
+ text1 = ' output-dir = '
+ text2 = '(required) path of directory to write the generated .csv file.'
+ logger.info(text1 + text2)
+ text1 = ' output-name = '
+ text2 = '(optional) name of the generated .csv file [default is name of input file with .csv suffix].'
+ logger.info(text1 + text2)
+ text1 = ' output-overwrite = '
+ text2 = '(optional) true [default] or false; replace existing output when true.'
+ logger.info(text1 + text2)
+ text1 = ' level = '
+ text2 = f'(optional) one of: {level_control} or {level_statement} [default].'
+ logger.info(text1 + text2)
+
+ def simulate(self) -> TaskOutcome:
+ """Provide a simulated outcome."""
+ return TaskOutcome('simulated-success')
+
+ def execute(self) -> TaskOutcome:
+ """Provide an actual outcome."""
+ try:
+ return self._execute()
+ except Exception:
+ logger.info(traceback.format_exc())
+ return TaskOutcome('failure')
+
+ def _execute(self) -> TaskOutcome:
+ """Wrap the execute for exception handling."""
+ # config processing
+ if not self._config:
+ logger.warning('config missing')
+ return TaskOutcome('failure')
+ # input
+ ifile = self._config.get('input-file')
+ if not ifile:
+ logger.warning('input-file missing')
+ return TaskOutcome('failure')
+ ipth = pathlib.Path(ifile)
+ # overwrite
+ self._overwrite = self._config.getboolean('output-overwrite', True)
+ # output
+ odir = self._config.get('output-dir')
+ if not odir:
+ logger.warning('output-dir missing')
+ return TaskOutcome('failure')
+ opth = pathlib.Path(odir)
+ opth.mkdir(exist_ok=True, parents=True)
+ iname = ipth.name.split('.')[0]
+ oname = self._config.get('output-name', f'{iname}.csv')
+ opth = opth / oname
+ if not self._overwrite and opth.exists():
+ logger.warning(f'output: {opth} already exists')
+ return TaskOutcome('failure')
+ csv_helper = CsvHelper(opth)
+ # level
+ level = self._config.get('level', level_default)
+ if level not in level_list:
+ logger.warning(f'level: {level} unknown')
+ return TaskOutcome('failure')
+ # helper
+ catalog_helper = CatalogHelper(ipth)
+ # process
+ content_manager = ContentManager(catalog_helper)
+ rows = content_manager.get_content(level)
+ # write
+ csv_helper.write(rows)
+ logger.info(f'output-file: {opth}')
+ # success
+ return TaskOutcome('success')