diff --git a/pyproject.toml b/pyproject.toml index 26e1d286..cd021bbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ trestlebot-autosync = "trestlebot.entrypoints.autosync:main" trestlebot-rules-transform = "trestlebot.entrypoints.rule_transform:main" trestlebot-create-cd = "trestlebot.entrypoints.create_cd:main" trestlebot-sync-upstreams = "trestlebot.entrypoints.sync_upstreams: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 a28d4347..f0d5df63 100644 --- a/tests/e2e/test_e2e_ssp.py +++ b/tests/e2e/test_e2e_ssp.py @@ -36,8 +36,8 @@ prepare_upstream_repo, 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__) @@ -78,17 +78,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( @@ -111,33 +100,40 @@ def test_ssp_editing_e2e( tmp_repo_path = pathlib.Path(tmp_repo_str) 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, image_name + ) + 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, image_name) run_response = subprocess.run(command, capture_output=True) assert run_response.returncode == response @@ -151,8 +147,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 diff --git a/tests/testutils.py b/tests/testutils.py index 68fe2c8d..6321ab18 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -27,6 +27,7 @@ JSON_TEST_DATA_PATH = pathlib.Path("tests/data/json/").resolve() YAML_TEST_DATA_PATH = pathlib.Path("tests/data/yaml/").resolve() TEST_SSP_INDEX = JSON_TEST_DATA_PATH / "test_ssp_index.json" +TEST_YAML_HEADER = YAML_TEST_DATA_PATH / "extra_yaml_header.yaml" # E2E test constants TRESTLEBOT_TEST_IMAGE_NAME = "localhost/trestlebot:latest" diff --git a/tests/trestlebot/entrypoints/test_create_ssp.py b/tests/trestlebot/entrypoints/test_create_ssp.py new file mode 100644 index 00000000..8568ace6 --- /dev/null +++ b/tests/trestlebot/entrypoints/test_create_ssp.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024 Red Hat, Inc. + + +"""Test for Create SSP CLI""" + +import logging +import pathlib +from typing import Any, Dict, Tuple +from unittest.mock import patch + +import pytest +from git import Repo + +from tests.testutils import TEST_YAML_HEADER, args_dict_to_list, setup_for_ssp +from trestlebot.entrypoints.create_ssp import main as cli_main + + +@pytest.fixture +def base_args_dict() -> Dict[str, str]: + return { + "branch": "main", + "committer-name": "test", + "profile-name": "simplified_nist_profile", + "ssp-name": "test-ssp", + "committer-email": "test@email.com", + "working-dir": ".", + "file-patterns": ".", + } + + +test_cat = "simplified_nist_catalog" +test_prof = "simplified_nist_profile" +test_comp_name = "test_comp" +test_ssp_md = "md_ssp" +test_repo_url = "git.test.com/test/repo.git" + +# Expecting more md files to be included in the commit +# but checking that the md directory was created and the ssp-index.json was created +expected_files = [ + "md_ssp/test-ssp/ac/ac-2.1.md", + "system-security-plans/test-ssp/system-security-plan.json", + "ssp-index.json", +] + + +def test_create_ssp( + tmp_repo: Tuple[str, Repo], base_args_dict: Dict[str, str], caplog: Any +) -> None: + """Test create-ssp with valid args and a custom yaml header.""" + repo_path, repo = tmp_repo + tmp_repo_path = pathlib.Path(repo_path) + repo.create_remote("origin", url=test_repo_url) + + args_dict = base_args_dict + args_dict["working-dir"] = repo_path + args_dict["markdown-path"] = test_ssp_md + args_dict["compdefs"] = test_comp_name + args_dict["ssp-index-path"] = str(tmp_repo_path / "ssp-index.json") + args_dict["yaml-header-path"] = str(TEST_YAML_HEADER) + + _ = setup_for_ssp(tmp_repo_path, test_prof, [test_comp_name], test_ssp_md) + + with patch("git.remote.Remote.push") as mock_push, patch( + "sys.argv", ["trestlebot", *args_dict_to_list(args_dict)] + ): + mock_push.return_value = "Mocked Results" + with pytest.raises(SystemExit, match="0"): + cli_main() + + # Verify that the correct files were included + commit = next(repo.iter_commits()) + assert all(file in commit.stats.files for file in expected_files) + assert len(commit.stats.files) == 17 + + assert any( + record.levelno == logging.INFO + and "Changes pushed to main successfully" in record.message + 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..c365c5be --- /dev/null +++ b/trestlebot/entrypoints/create_ssp.py @@ -0,0 +1,143 @@ +#!/usr/bin/python + +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024 Red Hat, Inc. + +""" +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 SUCCESS_EXIT_CODE +from trestlebot.entrypoints.entrypoint_base import ( + EntrypointBase, + comma_sep_to_list, + handle_exception, +) +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=True, + help="Name of profile in the trestle workspace to include in the SSP.", + ) + self.parser.add_argument( + "--compdefs", + required=True, + type=str, + help="Comma-separated list of component definitions to include in the SSP", + ) + self.parser.add_argument( + "--leveraged-ssp", + required=False, + type=str, + help="Provider SSP to leverage for the new SSP.", + ) + self.parser.add_argument( + "--markdown-path", + required=True, + type=str, + help="Path to create markdown files in.", + ) + self.parser.add_argument( + "--yaml-header-path", + required=False, + type=str, + help="Optionally set a path to a YAML file for custom SSP Markdown YAML headers.", + ) + 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 set the path to the SSP index file.", + ) + + def run(self, args: argparse.Namespace) -> None: + """Run the entrypoint.""" + exit_code: int = SUCCESS_EXIT_CODE + try: + set_log_level_from_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) + + ssp_index = SSPIndex(args.ssp_index_path) + authored_ssp = AuthoredSSP(args.working_dir, ssp_index) + + comps: List[str] = comma_sep_to_list(args.compdefs) + 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, + yaml_header=args.yaml_header_path, + ) + + # The starting point for SSPs is 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: List[TaskBase] = [assemble_task] + + super().run_base(args, pre_tasks) + except Exception as e: + exit_code = handle_exception(e) + + sys.exit(exit_code) + + +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()