From 670d423d9c02fb7aebf9c77fb3f90e9551a217b3 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Fri, 1 Dec 2023 18:21:56 -0500 Subject: [PATCH] feat: adds create-ssp entrypoint and tests Signed-off-by: Jennifer Power --- pyproject.toml | 1 + tests/e2e/test_e2e_ssp.py | 62 +++--- .../trestlebot/entrypoints/test_create_ssp.py | 106 +++++++++++ trestlebot/entrypoints/create_ssp.py | 180 ++++++++++++++++++ 4 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 tests/trestlebot/entrypoints/test_create_ssp.py create mode 100644 trestlebot/entrypoints/create_ssp.py diff --git a/pyproject.toml b/pyproject.toml index c46c064f..7d51dfba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ repository = 'https://github.com/RedHatProductSecurity/trestle-bot' trestlebot-autosync = "trestlebot.entrypoints.autosync:main" trestlebot-rules-transform = "trestlebot.entrypoints.rule_transform:main" trestlebot-create-cd = "trestlebot.entrypoints.create_cd:main" +trestlebot-create-ssp = "trestlebot.entrypoints.create_ssp:main" [tool.poetry.dependencies] python = '^3.8.1' diff --git a/tests/e2e/test_e2e_ssp.py b/tests/e2e/test_e2e_ssp.py index 2bbf0148..46c153d8 100644 --- a/tests/e2e/test_e2e_ssp.py +++ b/tests/e2e/test_e2e_ssp.py @@ -37,8 +37,8 @@ from trestle.oscal.ssp import SystemSecurityPlan from tests.testutils import build_test_command, setup_for_ssp -from trestlebot.const import ERROR_EXIT_CODE, INVALID_ARGS_EXIT_CODE, SUCCESS_EXIT_CODE -from trestlebot.tasks.authored.ssp import AuthoredSSP, SSPIndex +from trestlebot.const import ERROR_EXIT_CODE, SUCCESS_EXIT_CODE +from trestlebot.tasks.authored.ssp import SSPIndex logger = logging.getLogger(__name__) @@ -79,17 +79,6 @@ ERROR_EXIT_CODE, True, ), - ( - "failure/missing args", - { - "branch": "test", - "oscal-model": "ssp", - "committer-name": "test", - "committer-email": "test@email.com", - }, - INVALID_ARGS_EXIT_CODE, - False, - ), ], ) def test_ssp_editing_e2e( @@ -123,34 +112,38 @@ def test_ssp_editing_e2e( init._run(args) args = setup_for_ssp(tmp_repo_path, test_prof, [test_comp_name], test_ssp_md) + remote_url = "http://localhost:8080/test.git" + repo.create_remote("origin", url=remote_url) # Create or generate the SSP - if not skip_create: - index_path = os.path.join(tmp_repo_str, "ssp-index.json") - ssp_index = SSPIndex(index_path) - authored_ssp = AuthoredSSP(tmp_repo_str, ssp_index) - authored_ssp.create_new_default( + create_args: Dict[str, str] = { + "markdown-path": command_args["markdown-path"], + "branch": command_args["branch"], + "committer-name": command_args["committer-name"], + "committer-email": command_args["committer-email"], + "ssp-name": test_ssp_name, + "profile-name": test_prof, + "compdefs": test_comp_name, + } + command = build_test_command(tmp_repo_str, "create-ssp", create_args) + run_response = subprocess.run(command, capture_output=True) + assert run_response.returncode == response + assert (tmp_repo_path / command_args["markdown-path"]).exists() + + # Make a change to the SSP + ssp, ssp_path = ModelUtils.load_model_for_class( + tmp_repo_path, test_ssp_name, - test_prof, - [test_comp_name], - test_ssp_md, + SystemSecurityPlan, + FileContentType.JSON, ) + ssp.metadata.title = "New Title" + ssp.oscal_write(ssp_path) else: ssp_generate = SSPGenerate() assert ssp_generate._run(args) == 0 - ssp_path: pathlib.Path = ModelUtils.get_model_path_for_name_and_class( - tmp_repo_path, - test_ssp_name, - SystemSecurityPlan, - FileContentType.JSON, - ) - assert not ssp_path.exists() - - remote_url = "http://localhost:8080/test.git" - repo.create_remote("origin", url=remote_url) - command = build_test_command(tmp_repo_str, "autosync", command_args) run_response = subprocess.run(command, capture_output=True) assert run_response.returncode == response @@ -164,9 +157,8 @@ def test_ssp_editing_e2e( ) # Check that the correct files are present with the correct content - assert (tmp_repo_path / command_args["markdown-path"]).exists() - ssp_index.reload() + index_path = os.path.join(tmp_repo_str, "ssp-index.json") + ssp_index = SSPIndex(index_path) assert ssp_index.get_profile_by_ssp(test_ssp_name) == test_prof assert ssp_index.get_comps_by_ssp(test_ssp_name) == [test_comp_name] assert ssp_index.get_leveraged_by_ssp(test_ssp_name) is None - assert ssp_path.exists() diff --git a/tests/trestlebot/entrypoints/test_create_ssp.py b/tests/trestlebot/entrypoints/test_create_ssp.py new file mode 100644 index 00000000..684dde36 --- /dev/null +++ b/tests/trestlebot/entrypoints/test_create_ssp.py @@ -0,0 +1,106 @@ +#!/usr/bin/python + +# Copyright 2023 Red Hat, Inc. +# +# 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 +# +# http://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. + +"""Test for create-ssp CLI""" + +import logging +from typing import Any, Dict +from unittest.mock import patch + +import pytest + +from tests.testutils import args_dict_to_list +from trestlebot.entrypoints.create_ssp import main as cli_main + + +@pytest.fixture +def valid_args_dict() -> Dict[str, str]: + return { + "branch": "main", + "ssp-name": "ssp", + "markdown-path": "/my/path", + "profile-name": "profile", + "compdefs": "compdefs", + "committer-name": "test", + "committer-email": "test@email.com", + "working-dir": "tmp", + "file-patterns": ".", + } + + +def test_with_filtered_leveraged_ssp( + valid_args_dict: Dict[str, str], caplog: Any +) -> None: + """Test with filtered and leveraged ssp set.""" + args_dict = valid_args_dict + args_dict["filtered-ssp"] = "filtered-ssp" + args_dict["leveraged-ssp"] = "leveraged-ssp" + with patch("sys.argv", ["trestlebot", *args_dict_to_list(args_dict)]): + with pytest.raises(SystemExit, match="2"): + cli_main() + + assert any( + record.levelno == logging.ERROR + and record.message == "Cannot use both --filtered-ssp and --leveraged-ssp" + for record in caplog.records + ) + + +def test_with_no_profile(valid_args_dict: Dict[str, str], caplog: Any) -> None: + """Test with non profile name set.""" + args_dict = valid_args_dict + args_dict["profile-name"] = "" + with patch("sys.argv", ["trestlebot", *args_dict_to_list(args_dict)]): + with pytest.raises(SystemExit, match="2"): + cli_main() + + assert any( + record.levelno == logging.ERROR + and record.message == "Must set profile name with --profile-name." + for record in caplog.records + ) + + +def test_with_no_compdef(valid_args_dict: Dict[str, str], caplog: Any) -> None: + """Test with non compdef set.""" + args_dict = valid_args_dict + args_dict["compdef"] = "" + with patch("sys.argv", ["trestlebot", *args_dict_to_list(args_dict)]): + with pytest.raises(SystemExit, match="2"): + cli_main() + + assert any( + record.levelno == logging.ERROR + and record.message == "Must set component definitions with --compdefs." + for record in caplog.records + ) + + +def test_with_no_filter_criteria(valid_args_dict: Dict[str, str], caplog: Any) -> None: + """Test with invalid args for filtering.""" + args_dict = valid_args_dict + args_dict["filtered-ssp"] = "filtered-ssp" + args_dict["profile-name"] = "" + args_dict["compdefs"] = "" + with patch("sys.argv", ["trestlebot", *args_dict_to_list(args_dict)]): + with pytest.raises(SystemExit): + cli_main() + + err_msg = """Must filter by profile or component definitions with --profile-name and --compdefs.""" + assert any( + record.levelno == logging.ERROR and record.message == err_msg + for record in caplog.records + ) diff --git a/trestlebot/entrypoints/create_ssp.py b/trestlebot/entrypoints/create_ssp.py new file mode 100644 index 00000000..aa1ed128 --- /dev/null +++ b/trestlebot/entrypoints/create_ssp.py @@ -0,0 +1,180 @@ +#!/usr/bin/python + +# Copyright 2023 Red Hat, Inc. +# +# 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 +# +# http://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. + +""" +Entrypoint for system security plan bootstrapping. + +This will create and initial SSP with markdown, an SSP index, and a SSP JSON file. +""" + +import argparse +import logging +import pathlib +import sys +from typing import List + +from trestlebot.const import INVALID_ARGS_EXIT_CODE +from trestlebot.entrypoints.entrypoint_base import EntrypointBase, comma_sep_to_list +from trestlebot.entrypoints.log import set_log_level_from_args +from trestlebot.tasks.assemble_task import AssembleTask +from trestlebot.tasks.authored.ssp import AuthoredSSP, SSPIndex +from trestlebot.tasks.base_task import ModelFilter, TaskBase + + +logger = logging.getLogger(__name__) + + +class CreateSSPEntrypoint(EntrypointBase): + """Entrypoint for ssp bootstrapping.""" + + def __init__(self, parser: argparse.ArgumentParser) -> None: + """Initialize.""" + super().__init__(parser) + self.setup_create_ssp_arguments() + + def setup_create_ssp_arguments(self) -> None: + """Setup specific arguments for this entrypoint.""" + self.parser.add_argument( + "--ssp-name", required=True, type=str, help="Name of SSP to create." + ) + self.parser.add_argument( + "--profile-name", + required=False, + help="Name of profile in the trestle workspace to include in the SSP.", + ) + self.parser.add_argument( + "--compdefs", + required=False, + type=str, + help="Comma-separated list of component definitions to include in the SSP", + ) + self.parser.add_argument( + "--filtered-ssp", + required=False, + type=str, + help="Path to a SSP to filter for the new SSP.", + ) + self.parser.add_argument( + "--leveraged-ssp", + required=False, + type=str, + help="Provider SSP to leverage for the new SSP. Cannot be used with --filtered-ssp.", + ) + self.parser.add_argument( + "--markdown-path", + required=True, + type=str, + help="Path to create markdown files in.", + ) + self.parser.add_argument( + "--version", + required=False, + type=str, + help="Optionally set the SSP version.", + ) + self.parser.add_argument( + "--ssp-index-path", + required=False, + type=str, + default="ssp-index.json", + help="Optionally filter the controls in the component definition by a profile.", + ) + + def validate_args(self, args: argparse.Namespace) -> None: + """Validate arguments.""" + if args.filtered_ssp: + if args.leveraged_ssp: + logger.error("Cannot use both --filtered-ssp and --leveraged-ssp") + sys.exit(INVALID_ARGS_EXIT_CODE) + # Profile or component definitions are required for SSP filtering. + if not args.profile_name and not args.compdefs: + logger.error( + "Must filter by profile or component definitions with --profile-name and --compdefs." + ) + sys.exit(INVALID_ARGS_EXIT_CODE) + + # Profile and component definitions are required for a new SSP. + if not args.filtered_ssp: + if not args.profile_name: + logger.error("Must set profile name with --profile-name.") + sys.exit(INVALID_ARGS_EXIT_CODE) + if not args.compdefs: + logger.error("Must set component definitions with --compdefs.") + sys.exit(INVALID_ARGS_EXIT_CODE) + + def run(self, args: argparse.Namespace) -> None: + """Run the entrypoint.""" + + set_log_level_from_args(args) + self.validate_args(args) + + # If the ssp index file does not exist, create it. + ssp_index_path = pathlib.Path(args.ssp_index_path) + if not ssp_index_path.exists(): + # Create a parent directory + ssp_index_path.parent.mkdir(parents=True, exist_ok=True) + + pre_tasks: List[TaskBase] = [] + ssp_index = SSPIndex(args.ssp_index_path) + authored_ssp = AuthoredSSP(args.working_dir, ssp_index) + + comps: List[str] = comma_sep_to_list(args.compdefs) + if args.filtered_ssp: + # Create with filter starts with an existing SSP and filters it. + # No need to assemble. + authored_ssp.create_new_with_filter( + ssp_name=args.ssp_name, + input_ssp=args.filtered_ssp, + version=args.version, + markdown_path=args.markdown_path, + profile_name=args.profile_name, + compdefs=comps, + ) + else: + authored_ssp.create_new_default( + ssp_name=args.ssp_name, + profile_name=args.profile_name, + compdefs=comps, + markdown_path=args.markdown_path, + leveraged_ssp=args.leveraged_ssp, + ) + + # The starting point for SSPs in the markdown, so assemble into JSON. + model_filter: ModelFilter = ModelFilter([], [args.ssp_name]) + assemble_task = AssembleTask( + authored_object=authored_ssp, + markdown_dir=args.markdown_path, + version=args.version, + model_filter=model_filter, + ) + pre_tasks.append(assemble_task) + + super().run_base(args, pre_tasks) + + +def main() -> None: + """Run the CLI.""" + parser = argparse.ArgumentParser( + description="Create new system security plan for editing." + ) + create_ssp = CreateSSPEntrypoint(parser=parser) + + args = parser.parse_args() + create_ssp.run(args) + + +if __name__ == "__main__": + main()