diff --git a/README.md b/README.md index 354cb74d7..f161fb06f 100644 --- a/README.md +++ b/README.md @@ -96,28 +96,7 @@ Compliance trestle is currently stable and is based on NIST OSCAL version 1.1.2, ## Community meetings and communications -##### Scheduled meetings - -Please attend! All are invited. - -**When**: - -Every other Tuesday starting on April 23, 2024 · 11:00 – 11:30am ET -[convert to your local time](https://mytime.io/11am/ET) - -**Where**: [Google Meet Link](https://meet.google.com/mwp-affd-tvu) - -Dial in: -(US) +1 402-627-0247 PIN: 535 362 764#\ -[More phone numbers](https://tel.meet/mwp-affd-tvu?pin=9717189704231) - -**What**: Meeting agenda and notes [Google Docs](https://docs.google.com/document/d/1XTYM7xnWlIqd-8Nn5-qtgvgk8kH3NSmYle5yZvaS7qs/edit?usp=sharing) - -##### Chat anytime - -Slack: [#oscal-compliance-trestle-agileauthoring-c2p](https://cloud-native.slack.com/archives/C06F3PEPNBW) - -- **Note**: You can login to Slack using another account like Google, Apple +Please refer to the community [README](https://github.com/oscal-compass/community/blob/main/README.md) for communication details. ## Contributing to Trestle diff --git a/docs/python_trestle_setup.md b/docs/python_trestle_setup.md index 296440006..c1853c7a1 100644 --- a/docs/python_trestle_setup.md +++ b/docs/python_trestle_setup.md @@ -10,11 +10,11 @@ There are a few things you need to to start using trestle: ## *Confirm you have python installed* -- Ensure you have a modern [Python](https://www.python.org/downloads/) (3.7, 3.8, 3.9). +- Ensure you have a modern [Python](https://www.python.org/downloads/) (3.9, 3.10, 3.11). ```bash $ python -V -Python 3.8.3 +Python 3.9.2 ``` ## *Setup a virtual environment* diff --git a/scripts/oscal_normalize.py b/scripts/oscal_normalize.py index cd5fe7242..73a37b295 100644 --- a/scripts/oscal_normalize.py +++ b/scripts/oscal_normalize.py @@ -1033,7 +1033,13 @@ def kill_roots(file_classes): for ii in range(1, len(c.lines)): line = c.lines[ii] for name, body in new_root_classes.items(): - # handle special case + # handle special cases + if 'NonNegativeIntegerDatatype' in line: + line = line.replace('NonNegativeIntegerDatatype', 'conint(ge=0, multiple_of=1)', 1) + if 'PositiveIntegerDatatype' in line: + line = line.replace('PositiveIntegerDatatype', 'conint(ge=1, multiple_of=1)', 1) + if 'IntegerDatatype' in line: + line = line.replace('IntegerDatatype', 'conint(multiple_of=1)', 1) if c.name == 'OscalVersion': if any(token in line for token in [' __root__: StringDatatype']): line = line.replace(name, body, 1) diff --git a/tests/data/validate/component-definitions/x1/component-definition.json b/tests/data/validate/component-definitions/x1/component-definition.json new file mode 100644 index 000000000..0dc7ec5fd --- /dev/null +++ b/tests/data/validate/component-definitions/x1/component-definition.json @@ -0,0 +1,20 @@ +{ + "component-definition": { + "uuid": "e7ba3d37-2ba0-4db9-9cf8-f5ec1214ffd5", + "metadata": { + "title": "Generic component-definition created by trestle named replication.", + "last-modified": "2024-06-11T21:16:49.175968+00:00", + "version": "0.0.0", + "oscal-version": "1.0.4" + }, + "components": [ + { + "uuid": "4cef9684-578e-460e-a2d1-6eb78b8a76ca", + "type": "REPLACE_ME", + "title": "REPLACE_ME", + "description": "REPLACE_ME", + "control-implementations": [] + } + ] + } +} \ No newline at end of file diff --git a/tests/data/validate/component-definitions/x2/component-definition.json b/tests/data/validate/component-definitions/x2/component-definition.json new file mode 100644 index 000000000..69c85ee2c --- /dev/null +++ b/tests/data/validate/component-definitions/x2/component-definition.json @@ -0,0 +1,34 @@ +{ + "component-definition": { + "uuid": "e7ba3d37-2ba0-4db9-9cf8-f5ec1214ffd5", + "metadata": { + "title": "Generic component-definition created by trestle named replication.", + "last-modified": "2024-06-11T21:16:49.175968+00:00", + "version": "0.0.0", + "oscal-version": "1.0.4" + }, + "components": [ + { + "uuid": "4cef9684-578e-460e-a2d1-6eb78b8a76ca", + "type": "REPLACE_ME", + "title": "REPLACE_ME", + "description": "REPLACE_ME", + "protocols": [ + { + "uuid": "0130b4ef-4561-4363-bab5-1e20b9888e49", + "name": "https", + "title": "Transport Layer Security", + "port-ranges": [ + { + "start": 443, + "end": 443, + "transport": "TCP" + } + ] + } + ], + "control-implementations": [] + } + ] + } +} \ No newline at end of file diff --git a/tests/data/validate/component-definitions/x3/component-definition.json b/tests/data/validate/component-definitions/x3/component-definition.json new file mode 100644 index 000000000..1fadb7114 --- /dev/null +++ b/tests/data/validate/component-definitions/x3/component-definition.json @@ -0,0 +1,34 @@ +{ + "component-definition": { + "uuid": "e7ba3d37-2ba0-4db9-9cf8-f5ec1214ffd5", + "metadata": { + "title": "Generic component-definition created by trestle named replication.", + "last-modified": "2024-06-11T21:16:49.175968+00:00", + "version": "0.0.0", + "oscal-version": "1.0.4" + }, + "components": [ + { + "uuid": "4cef9684-578e-460e-a2d1-6eb78b8a76ca", + "type": "REPLACE_ME", + "title": "REPLACE_ME", + "description": "REPLACE_ME", + "protocols": [ + { + "uuid": "0130b4ef-4561-4363-bab5-1e20b9888e49", + "name": "https", + "title": "Transport Layer Security", + "port-ranges": [ + { + "start": -443, + "end": 443, + "transport": "TCP" + } + ] + } + ], + "control-implementations": [] + } + ] + } +} \ No newline at end of file diff --git a/tests/trestle/core/commands/validate_test.py b/tests/trestle/core/commands/validate_test.py index 4f14a0893..8eb02f457 100644 --- a/tests/trestle/core/commands/validate_test.py +++ b/tests/trestle/core/commands/validate_test.py @@ -30,6 +30,7 @@ import trestle.common.const as const import trestle.core.generators as gens import trestle.oscal.assessment_plan as ap +import trestle.oscal.common as common import trestle.oscal.profile as prof import trestle.oscal.ssp as ossp from trestle import cli @@ -407,3 +408,65 @@ def test_validate_ssp_with_no_profile(tmp_trestle_dir: pathlib.Path, monkeypatch new_ssp.import_profile.href = original_href ModelUtils.save_top_level_model(new_ssp, tmp_trestle_dir, ssp_name, FileContentType.JSON) + + +def test_period(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None: + """Test period.""" + unit = common.TimeUnitValidValues.seconds + _ = common.AtFrequency(period=1, unit=unit) + try: + _ = common.AtFrequency(period=0, unit=unit) + raise RuntimeError('must be positive integer') + except Exception: + pass + + +def test_validate_component_definition(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None: + """Test validation of Component Definition.""" + jfile = 'component-definition.json' + + sdir = test_data_dir / 'validate' / 'component-definitions' / 'x1' + spth = sdir / f'{jfile}' + + tdir = tmp_trestle_dir / test_utils.COMPONENT_DEF_DIR / 'my_test_model' + tpth = tdir / f'{jfile}' + + (tdir).mkdir(exist_ok=True, parents=True) + + shutil.copyfile(spth, tpth) + validate_command = f'trestle validate -f {tpth}' + test_utils.execute_command_and_assert(validate_command, 0, monkeypatch) + + +def test_validate_component_definition_ports(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None: + """Test validation of ports in Component Definition.""" + jfile = 'component-definition.json' + + sdir = test_data_dir / 'validate' / 'component-definitions' / 'x2' + spth = sdir / f'{jfile}' + + tdir = tmp_trestle_dir / test_utils.COMPONENT_DEF_DIR / 'my_test_model' + tpth = tdir / f'{jfile}' + + (tdir).mkdir(exist_ok=True, parents=True) + + shutil.copyfile(spth, tpth) + validate_command = f'trestle validate -f {tpth}' + test_utils.execute_command_and_assert(validate_command, 0, monkeypatch) + + +def test_validate_component_definition_ports_invalid(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None: + """Test validation of ports in Component Definition.""" + jfile = 'component-definition.json' + + sdir = test_data_dir / 'validate' / 'component-definitions' / 'x3' + spth = sdir / f'{jfile}' + + tdir = tmp_trestle_dir / test_utils.COMPONENT_DEF_DIR / 'my_test_model' + tpth = tdir / f'{jfile}' + + (tdir).mkdir(exist_ok=True, parents=True) + + shutil.copyfile(spth, tpth) + validate_command = f'trestle validate -f {tpth}' + test_utils.execute_command_and_assert(validate_command, 1, monkeypatch) diff --git a/tests/trestle/tasks/csv_to_oscal_cd_test.py b/tests/trestle/tasks/csv_to_oscal_cd_test.py index 392b4a534..e2ebc1fa3 100644 --- a/tests/trestle/tasks/csv_to_oscal_cd_test.py +++ b/tests/trestle/tasks/csv_to_oscal_cd_test.py @@ -885,6 +885,34 @@ def test_execute_param_duplicate_value(tmp_path: pathlib.Path) -> None: assert retval == TaskOutcome.FAILURE +def test_execute_correct_rule_key(tmp_path: pathlib.Path) -> None: + """Test execute missing value.""" + _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd.config') + rows = _get_rows('tests/data/csv/ocp4-user.v2.csv') + row = rows[2] + # ensure component_title and component_description are different + assert row[7] == 'OSCO' + assert row[8] == 'OSCO' + row[8] = 'IAM' + assert row[8] == 'IAM' + component_title = row[7] + component_description = row[8] + component_type = row[5] + rule_id = row[1] + with mock.patch('trestle.tasks.csv_to_oscal_cd.csv.reader') as mock_csv_reader: + mock_csv_reader.return_value = rows + # perform transformation + tgt = csv_to_oscal_cd.CsvToOscalComponentDefinition(section) + retval = tgt.execute() + assert retval == TaskOutcome.SUCCESS + # insure expected key exists + expected_key = (component_title, component_type, rule_id) + assert expected_key in tgt._csv_mgr.get_rule_keys() + # insure unexpected key does not exist + unexpected_key = (component_description, component_type, rule_id) + assert unexpected_key not in tgt._csv_mgr.get_rule_keys() + + def test_execute_missing_param_default_value(tmp_path: pathlib.Path) -> None: """Test execute missing param default_value.""" _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config') @@ -1234,6 +1262,87 @@ def test_execute_add_property(tmp_path: pathlib.Path) -> None: assert component.props[6].value == 'add-fetcher-description' +def test_execute_with_risk_properties(tmp_path: pathlib.Path) -> None: + """Test execute with risk properties.""" + _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config') + # add risk properties + rows = _get_rows('tests/data/csv/bp.sample.v2.csv') + rows[0].append('Original_Risk_Rating') + rows[0].append('Adjusted_Risk_Rating') + rows[0].append('Risk_Adjustment') + # row 3 will be tested + rows[2].append('add-original-risk-rating') + rows[2].append('add-adjusted-risk-rating') + rows[2].append('add-risk-adjustment') + # ensure all rows have a value for the risk columns to execute task successfully + for row in rows[3:]: + row.append('') + row.append('') + row.append('') + with mock.patch('trestle.tasks.csv_to_oscal_cd.csv.reader') as mock_csv_reader: + mock_csv_reader.return_value = rows + tgt = csv_to_oscal_cd.CsvToOscalComponentDefinition(section) + retval = tgt.execute() + assert retval == TaskOutcome.SUCCESS + # read component-definition + fp = pathlib.Path(tmp_path) / 'component-definition.json' + cd = ComponentDefinition.oscal_read(fp) + # spot check + component = cd.components[0] + # the bp.sample.v2.csv before mock has 62 properties + assert len(component.props) == 65 + assert component.props[0].name == 'Rule_Id' + assert component.props[1].name == 'Rule_Description' + assert component.props[2].name == 'Check_Id' + assert component.props[3].name == 'Check_Description' + assert component.props[4].name == 'Original_Risk_Rating' + assert component.props[4].value == 'add-original-risk-rating' + assert component.props[5].name == 'Adjusted_Risk_Rating' + assert component.props[5].value == 'add-adjusted-risk-rating' + assert component.props[6].name == 'Risk_Adjustment' + assert component.props[6].value == 'add-risk-adjustment' + + +def test_execute_with_ignored_risk_properties(tmp_path: pathlib.Path) -> None: + """Test execute with ignored risk properties when component type is validation.""" + _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config') + # add risk properties + rows = _get_rows('tests/data/csv/bp.sample.v2.csv') + rows[0].append('Original_Risk_Rating') + rows[0].append('Adjusted_Risk_Rating') + rows[0].append('Risk_Adjustment') + # row 3 will be tested + rows[2].append('add-original-risk-rating') + rows[2].append('add-adjusted-risk-rating') + rows[2].append('add-risk-adjustment') + # ensure all rows have a value for the risk columns to execute task successfully + for row in rows[3:]: + row.append('') + row.append('') + row.append('') + # set validation component type + assert rows[0][9] == 'Component_Type' + assert rows[2][9] == 'Service' + rows[2][9] = 'Validation' + with mock.patch('trestle.tasks.csv_to_oscal_cd.csv.reader') as mock_csv_reader: + mock_csv_reader.return_value = rows + tgt = csv_to_oscal_cd.CsvToOscalComponentDefinition(section) + retval = tgt.execute() + assert retval == TaskOutcome.SUCCESS + # read component-definition + fp = pathlib.Path(tmp_path) / 'component-definition.json' + cd = ComponentDefinition.oscal_read(fp) + # spot check + component = cd.components[0] + assert component.type == 'Validation' + # there are 4 component validation props: Rule_Id, Check_Id, Check_Description, Reference_Id + assert len(component.props) == 4 + for prop in component.props: + assert prop.name != 'Original_Risk_Rating' + assert prop.name != 'Adjusted_Risk_Rating' + assert prop.name != 'Risk_Adjustment' + + def test_execute_add_user_property(tmp_path: pathlib.Path) -> None: """Test execute add user property.""" _, section = _get_config_section_init(tmp_path, 'test-csv-to-oscal-cd-bp.config') diff --git a/trestle/oscal/common.py b/trestle/oscal/common.py index 2908b7659..71a463c8f 100644 --- a/trestle/oscal/common.py +++ b/trestle/oscal/common.py @@ -408,6 +408,25 @@ class PortRangeValidValues(Enum): UDP = 'UDP' +class PortRange(OscalBaseModel): + """ + Where applicable this is the IPv4 port range on which the service operates. + """ + + class Config: + extra = Extra.forbid + + start: Optional[conint(ge=0, multiple_of=1)] = Field( + None, description='Indicates the starting port number in a port range', title='Start' + ) + end: Optional[conint(ge=0, multiple_of=1)] = Field( + None, description='Indicates the ending port number in a port range', title='End' + ) + transport: Optional[PortRangeValidValues] = Field( + None, description='Indicates the transport type.', title='Transport' + ) + + class PartyUuid(OscalBaseModel): __root__: UUIDDatatype = Field( ..., description='Reference to a party by UUID.', title='Party Universally Unique Identifier Reference' @@ -982,9 +1001,9 @@ class AtFrequency(OscalBaseModel): class Config: extra = Extra.forbid - period: PositiveIntegerDatatype = Field( - ..., description='The task must occur after the specified period has elapsed.', title='Period' - ) + period: conint( + ge=1, multiple_of=1 + ) = Field(..., description='The task must occur after the specified period has elapsed.', title='Period') unit: TimeUnitValidValues = Field(..., description='The unit of time for the period.', title='Time Unit') @@ -1557,25 +1576,6 @@ class Published(OscalBaseModel): ) -class PortRange(OscalBaseModel): - """ - Where applicable this is the IPv4 port range on which the service operates. - """ - - class Config: - extra = Extra.forbid - - start: Optional[NonNegativeIntegerDatatype] = Field( - None, description='Indicates the starting port number in a port range', title='Start' - ) - end: Optional[NonNegativeIntegerDatatype] = Field( - None, description='Indicates the ending port number in a port range', title='End' - ) - transport: Optional[PortRangeValidValues] = Field( - None, description='Indicates the transport type.', title='Transport' - ) - - class Protocol(OscalBaseModel): """ Information about the protocol used to provide a service. diff --git a/trestle/tasks/csv_to_oscal_cd.py b/trestle/tasks/csv_to_oscal_cd.py index 724473595..367359ca3 100644 --- a/trestle/tasks/csv_to_oscal_cd.py +++ b/trestle/tasks/csv_to_oscal_cd.py @@ -61,6 +61,9 @@ PARAMETER_DESCRIPTION = f'{PARAMETER}_Description' PARAMETER_VALUE_DEFAULT = f'{PARAMETER}_Value_Default' PARAMETER_VALUE_ALTERNATIVES = f'{PARAMETER}_Value_Alternatives' +ORIGINAL_RISK_RATING = 'Original_Risk_Rating' +ADJUSTED_RISK_RATING = 'Adjusted_Risk_Rating' +RISK_ADJUSTMENT = 'Risk_Adjustment' validation = 'validation' prefix_rule_set = 'rule_set_' @@ -190,7 +193,10 @@ def print_info(self) -> None: text1 = ' ' text1 = ' optional columns: ' for text2 in CsvColumn.get_optional_column_names(): - text2 += ' (see note 2)' + if text2 in [f'{ORIGINAL_RISK_RATING}', f'{ADJUSTED_RISK_RATING}', f'{RISK_ADJUSTMENT}']: + text2 += ' (see note 1)' + else: + text2 += ' (see note 2)' logger.info(text1 + '$' + text2) text1 = ' ' for text2 in CsvColumn.get_parameter_column_names(): @@ -1367,6 +1373,9 @@ class CsvColumn(): _columns_optional = [ f'{CHECK_ID}', f'{CHECK_DESCRIPTION}', + f'{ORIGINAL_RISK_RATING}', + f'{ADJUSTED_RISK_RATING}', + f'{RISK_ADJUSTMENT}', ] _columns_parameter = [ @@ -1440,6 +1449,9 @@ def get_required_column_names_validation() -> List[str]: f'{PARAMETER_VALUE_ALTERNATIVES}', f'{CHECK_ID}', f'{CHECK_DESCRIPTION}', + f'{ORIGINAL_RISK_RATING}', + f'{ADJUSTED_RISK_RATING}', + f'{RISK_ADJUSTMENT}', ] @staticmethod @@ -1533,7 +1545,7 @@ def __init__(self, csv_path: pathlib.Path) -> None: component_description = self.get_row_value(row, f'{COMPONENT_DESCRIPTION}') rule_id = self.get_row_value(row, f'{RULE_ID}') # rule sets - key = _CsvMgr.get_rule_key(component_description, component_type, rule_id) + key = _CsvMgr.get_rule_key(component_title, component_type, rule_id) if key in self._csv_rules_map: text = f'row "{row_num}" contains duplicate {RULE_ID} "{rule_id}"' raise RuntimeError(text)