diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml new file mode 100644 index 000000000..879988722 --- /dev/null +++ b/.github/workflows/cli-tests.yml @@ -0,0 +1,94 @@ +--- +name: CLI Smoke Tests + +on: + workflow_dispatch: + push: + branches: + - dev_2.0 + paths-ignore: + - 'archive/**' + pull_request: + branches: + - dev_2.0 + paths-ignore: + - 'archive/**' + +jobs: + cli-smoke-tests: + name: CLI smoke tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: list_platforms flag + run: python mscp.py --list_platforms + + - name: baseline list_tags + run: python mscp.py baseline --list_tags + + - name: baseline generate (macos 800-53r5 moderate) + run: > + python mscp.py + --os_name macos --os_version 26.0 + --output_dir /tmp/mscp-test-output + baseline --keyword 800-53r5_moderate + + - name: baseline generate (macos cis_lvl1) + run: > + python mscp.py + --os_name macos --os_version 26.0 + --output_dir /tmp/mscp-test-output + baseline --keyword cis_lvl1 + + - name: guidance markdown (macos 800-53r5 moderate) + run: > + python mscp.py + --os_name macos --os_version 26.0 + --output_dir /tmp/mscp-test-output + guidance --markdown --no-docs + baselines/macos/800-53r5_moderate_macos_26.0.yaml + + - name: guidance script (macos cis_lvl1) + run: > + python mscp.py + --os_name macos --os_version 26.0 + --output_dir /tmp/mscp-test-output + guidance --script --no-docs + baselines/macos/cis_lvl1_macos_26.0.yaml + + - name: scap xccdf (macos 800-53r5 moderate) + run: > + python mscp.py + --os_name macos --os_version 26.0 + --output_dir /tmp/mscp-test-output + scap --xccdf --baseline 800-53r5_moderate + + - name: admin validate + run: python mscp.py admin validate + + cli_tests_success: + needs: + - cli-smoke-tests + if: always() + name: CLI tests successful + runs-on: ubuntu-latest + steps: + - name: Check job status + if: >- + ${{ + ( + contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + ) + }} + run: exit 1 diff --git a/src/mscp/admin_utils/build_baselines.py b/src/mscp/admin_utils/build_baselines.py index f80b22862..e6a15a8c0 100644 --- a/src/mscp/admin_utils/build_baselines.py +++ b/src/mscp/admin_utils/build_baselines.py @@ -19,7 +19,8 @@ mscp_data, remove_dir_contents, ) -from ..classes import Macsecurityrule +from ..common_utils import logging_config +from ..classes.rule_library import RuleLibrary from ..generate import ( generate_baseline, ) @@ -52,6 +53,7 @@ def build_all_baselines(args: argparse.Namespace) -> None: new baseline YAML files into it. """ logger.info("Building all supported baselines...") + logging_config.suppress_spinner = True # clear existing default baselines baselines_dir = Path(config.get("baseline_dir", "")) @@ -75,20 +77,23 @@ def build_all_baselines(args: argparse.Namespace) -> None: "800-53r5_privacy", } - all_rules: list[Macsecurityrule] = Macsecurityrule.collect_all_rules( - args.os_name, args.os_version, args.tailor, parent_values="Default" - ) - - all_tags, benchmark_map = collect_tags_and_benchmarks(all_rules) + library = RuleLibrary.from_rules_dir() + all_tags, benchmark_map = collect_tags_and_benchmarks(list(library)) all_tags[:] = [x for x in all_tags if x not in excluded_tags] + # cache rules per (platform, version) so generate_baseline doesn't re-collect on every call + platform_rules = { + platform: list(library.by_platform(platform).by_os(os_version=float(args.os_version))) + for platform in mscp_data["versions"]["platforms"] + } + # process every discovered benchmark and generate a baseline file for keyword, platforms in benchmark_map.items(): args.keyword = keyword for platform in platforms: args.os_name = platform.lower() - generate_baseline(args, admin=True) + generate_baseline(args, admin=True, preloaded_rules=platform_rules.get(args.os_name, [])) # process every discovered tag and generate a baseline file for every supported platform for platform in mscp_data["versions"]["platforms"]: @@ -96,4 +101,4 @@ def build_all_baselines(args: argparse.Namespace) -> None: for tag in all_tags: args.keyword = tag - generate_baseline(args, admin=True) + generate_baseline(args, admin=True, preloaded_rules=platform_rules[platform]) diff --git a/src/mscp/classes/legacy_baseline.py b/src/mscp/classes/legacy_baseline.py index 5e4b03482..a43d4bf99 100644 --- a/src/mscp/classes/legacy_baseline.py +++ b/src/mscp/classes/legacy_baseline.py @@ -290,9 +290,9 @@ def migrate( ) # 3. Load the current rule library for the target platform/version. - # collect_all_rules assigns each rule its current section, so the + # collect_platform_rules assigns each rule its current section, so the # resulting baseline will reflect today's section structure. - all_current_rules = Macsecurityrule.collect_all_rules( + all_current_rules = Macsecurityrule.collect_platform_rules( os_type=os_type, os_version=int(os_version), parent_values=self.parent_values or "default", diff --git a/src/mscp/classes/macsecurityrule.py b/src/mscp/classes/macsecurityrule.py index 7d3d765db..1f602e51c 100644 --- a/src/mscp/classes/macsecurityrule.py +++ b/src/mscp/classes/macsecurityrule.py @@ -77,7 +77,7 @@ class Macsecurityrule(BaseModelWithAccessors): The top-level domain object for mSCP. Combines rule metadata (title, discussion, references), enforcement information (`check`, `fix`, `mechanism`), and platform / version targeting. Instances are normally - constructed via `load_rules` or `collect_all_rules` rather than + constructed via `load_rules` or `collect_platform_rules` rather than directly. Attributes: @@ -492,22 +492,23 @@ def load_rules( return rules @classmethod - def collect_all_rules( + def collect_platform_rules( cls, os_type: str, os_version: int, tailoring: bool = False, parent_values: str = "default", ) -> list["Macsecurityrule"]: - """Load every rule under ``config["rules_dir"]`` for an OS/version. + """Load every rule under ``config["rules_dir"]`` for a specific OS type and version. Walks each subfolder of the rules directory (skipping ``sysprefs``), maps each folder name through `Sectionmap` to the matching section file, and delegates per-section loading to - `load_rules`. + `load_rules`. Rules that do not declare support for the requested + ``os_type`` / ``os_version`` are skipped. Args: - os_type: Operating system family (e.g. ``"macOS"``). + os_type: Operating system family (e.g. ``"macos"``). os_version: Operating system version. tailoring: If true, skips customization overrides. Defaults to ``False``. @@ -515,7 +516,7 @@ def collect_all_rules( Defaults to ``"default"``. Returns: - All rules across all sections that match the given platform. + All rules across all sections that match the given platform and version. """ logger.info("=== LOADING ALL RULES ===") diff --git a/src/mscp/classes/rule_library.py b/src/mscp/classes/rule_library.py index e90e9ed18..152d3fd51 100644 --- a/src/mscp/classes/rule_library.py +++ b/src/mscp/classes/rule_library.py @@ -58,7 +58,7 @@ def from_rules_dir(cls) -> RuleLibrary: Reads the platform/version matrix from the bundled ``mscp-data.yaml`` (via ``mscp_data``) and calls - ``Macsecurityrule.collect_all_rules`` once per combination. Use + ``Macsecurityrule.collect_platform_rules`` once per combination. Use ``by_platform`` or ``by_os`` to narrow the result to a specific platform. @@ -76,7 +76,7 @@ def from_rules_dir(cls) -> RuleLibrary: if os_version is None: continue all_rules.extend( - Macsecurityrule.collect_all_rules( + Macsecurityrule.collect_platform_rules( os_type=os_type, os_version=os_version, ) diff --git a/src/mscp/cli.py b/src/mscp/cli.py index ffd67283b..7ba1d4d39 100644 --- a/src/mscp/cli.py +++ b/src/mscp/cli.py @@ -478,6 +478,14 @@ def parse_cli() -> None: action="store_true", ) + guidance_parser.add_argument( + "--no-docs", + dest="no_docs", + default=False, + help="skip generating the asciidoctor, PDF, and HTML documents", + action="store_true", + ) + mapping_parser: argparse.ArgumentParser = subparsers.add_parser( "mapping", help="generate custom rules from compliance framework mappings", diff --git a/src/mscp/common_utils/logging_config.py b/src/mscp/common_utils/logging_config.py index 506d7e2b7..319b8224f 100644 --- a/src/mscp/common_utils/logging_config.py +++ b/src/mscp/common_utils/logging_config.py @@ -5,7 +5,7 @@ ``-vv`` / ``--debug``, plus a rotating file sink under ``logs/mscp.log``. `function_filter` lets developers narrow stderr output to a single module via the ``MSCP_DEV_FILTER`` environment variable. -The module-level `verbose_logging` flag is read by +The module-level `verbose_logging` and `suppress_spinner` flags are read by `spinner_utils.conditional_inject_spinner` to decide whether to show a spinner. """ @@ -23,6 +23,7 @@ from .logger_instance import logger verbose_logging: bool = False +suppress_spinner: bool = False def function_filter(record): diff --git a/src/mscp/common_utils/spinner_utils.py b/src/mscp/common_utils/spinner_utils.py index eeb285eaf..07ba7859c 100644 --- a/src/mscp/common_utils/spinner_utils.py +++ b/src/mscp/common_utils/spinner_utils.py @@ -4,8 +4,8 @@ When verbose logging is active, log lines and a spinner clobber each other in the terminal. `conditional_inject_spinner` injects the spinner only when `logging_config.verbose_logging` is false; otherwise it runs -the wrapped function with a stopped spinner so the log output stays -clean. +the wrapped function with a no-op shim so calls like `sp.ok()` and +`sp.text =` are silently swallowed. """ import functools @@ -13,13 +13,24 @@ from . import logging_config +class _NoOpSpinner: + """Silent drop-in for a yaspin spinner used when output is suppressed.""" + + def __setattr__(self, name, value): + pass + + def __getattr__(self, name): + return lambda *a, **kw: None + + def conditional_inject_spinner(**spinner_kwargs): """Decorator factory that injects a `yaspin` spinner only when quiet. Behaves like `yaspin.inject_spinner` (the spinner is started before - the wrapped call and stopped after), but skips both start and stop - when `logging_config.verbose_logging` is true. The wrapped function - receives the spinner as its first positional argument. + the wrapped call and stopped after), but passes a no-op shim instead + when `logging_config.verbose_logging` or `logging_config.suppress_spinner` + is true, so calls like `sp.ok()` and `sp.text =` inside the wrapped + function produce no output. Args: **spinner_kwargs: Keyword arguments forwarded to `yaspin()` to @@ -33,16 +44,17 @@ def conditional_inject_spinner(**spinner_kwargs): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): - suppress = logging_config.verbose_logging + suppress = logging_config.verbose_logging or logging_config.suppress_spinner + + if suppress: + return func(_NoOpSpinner(), *args, **kwargs) sp = yaspin(**spinner_kwargs) - if not suppress: - sp.start() + sp.start() try: return func(sp, *args, **kwargs) finally: - if not suppress: - sp.stop() + sp.stop() return wrapper diff --git a/src/mscp/common_utils/validate_rules.py b/src/mscp/common_utils/validate_rules.py index 93e72f507..c3d361e40 100644 --- a/src/mscp/common_utils/validate_rules.py +++ b/src/mscp/common_utils/validate_rules.py @@ -12,6 +12,7 @@ # Standard python modules import argparse +import sys from pathlib import Path from jsonschema import Draft202012Validator @@ -106,9 +107,11 @@ def validate_yaml_file(args: argparse.Namespace) -> None: print(f"✅ VALID: {yaml}") logger.info(f"✅ VALID: {yaml}") - if not error_found: - print(f"✅ All YAML files passed validation.") - logger.success(f"✅ All YAML files passed validation.") + if error_found: + sys.exit(1) + + print(f"✅ All YAML files passed validation.") + logger.success(f"✅ All YAML files passed validation.") def validate_rule_folder_structure(path_str: str) -> Path: diff --git a/src/mscp/data/rules/system_settings/system_settings_biometric_disable.yaml b/src/mscp/data/rules/system_settings/system_settings_biometric_disable.yaml index 656714a0a..521c688c6 100644 --- a/src/mscp/data/rules/system_settings/system_settings_biometric_disable.yaml +++ b/src/mscp/data/rules/system_settings/system_settings_biometric_disable.yaml @@ -18,7 +18,7 @@ references: macos_14: - CCE-93003-2 visionos_26: - - CCE-95597-1 + - CCE-95597-1 800-53r5: - IA-5 800-171r3: @@ -69,7 +69,7 @@ platforms: visionOS: '26.0': supervised: true - introduced: '2.0' + introduced: '2.0' tags: - 800-53r5_low - 800-53r5_moderate diff --git a/src/mscp/generate/baseline.py b/src/mscp/generate/baseline.py index 4d83ae3ca..d6e59f631 100644 --- a/src/mscp/generate/baseline.py +++ b/src/mscp/generate/baseline.py @@ -14,9 +14,14 @@ from pathlib import Path from typing import Any +from ..common_utils import conditional_inject_spinner +from yaspin.core import Yaspin +from yaspin.spinners import Spinners + # Local python modules from ..classes import Author, Baseline, Macsecurityrule from ..classes.legacy_baseline import LegacyBaseline +from ..classes.rule_library import RuleLibrary from ..common_utils import ( config, logger, @@ -238,7 +243,13 @@ def migrate_legacy_baseline(args: argparse.Namespace) -> None: @logger.catch -def generate_baseline(args: argparse.Namespace, admin=False) -> None: +@conditional_inject_spinner() +def generate_baseline( + sp: Yaspin, + args: argparse.Namespace, + admin=False, + preloaded_rules: list[Macsecurityrule] | None = None, +) -> None: """Generate a YAML baseline file for the specified OS and keyword. Collects all rules matching ``args.keyword`` (tag or benchmark name), @@ -252,8 +263,14 @@ def generate_baseline(args: argparse.Namespace, admin=False) -> None: admin (bool): When ``True`` the output is written to the library's default baseline directory instead of the custom directory. Defaults to ``False``. + preloaded_rules: Pre-collected rules for ``args.os_name``. When + provided, skips the ``collect_platform_rules`` call. Useful + for bulk generation where the same platform rules are reused + across many calls. """ + sp.spinner = Spinners.dots if getattr(args, "migrate", None): + sp.text("Migrating baseline from 1.0") migrate_legacy_baseline(args) return @@ -306,16 +323,25 @@ def replace_vars(text: str) -> str: if not build_path.exists(): make_dir(build_path) - all_rules: list[Macsecurityrule] = Macsecurityrule.collect_all_rules( - args.os_name, args.os_version, args.tailor, parent_values="Default" - ) + if args.list_tags: + sp.text = "Loading Rules from MSCP Library" + all_library_rules = list(RuleLibrary.from_rules_dir()) + all_tags, benchmark_map = collect_tags_and_benchmarks(all_library_rules) + print_keyword_summary(all_tags, benchmark_map) + sp.ok("✔") + + if preloaded_rules is not None: + all_rules: list[Macsecurityrule] = preloaded_rules + else: + sp.text = f"Loading rules for {args.os_name} {args.os_version}" + all_rules = Macsecurityrule.collect_platform_rules( + args.os_name, args.os_version, args.tailor, parent_values="Default" + ) + sp.ok("✔") all_tags, benchmark_map = collect_tags_and_benchmarks(all_rules) established_benchmarks: tuple[str, ...] = collect_established_benchmarks(all_rules) - if args.list_tags: - print_keyword_summary(all_tags, benchmark_map) - if args.controls: included_controls: list[str] = sorted( { diff --git a/src/mscp/generate/guidance.py b/src/mscp/generate/guidance.py index 7ef0bdf06..6e9c82cce 100644 --- a/src/mscp/generate/guidance.py +++ b/src/mscp/generate/guidance.py @@ -403,20 +403,21 @@ def generate_guidance(sp: Yaspin, args: argparse.Namespace) -> None: time.sleep(1) generate_manifest(build_path, baseline_name, baseline) - logger.info("Generating asciidoctor, PDF, and HTML documents") - generate_documents( - sp, - adoc_output_file, - baseline, - b64logo, - pdf_theme, - html_css, - logo_path, - baseline.platform["os"], - current_version_data, - show_all_tags, - language=args.language, - ) + if not args.no_docs: + logger.info("Generating asciidoctor, PDF, and HTML documents") + generate_documents( + sp, + adoc_output_file, + baseline, + b64logo, + pdf_theme, + html_css, + logo_path, + baseline.platform["os"], + current_version_data, + show_all_tags, + language=args.language, + ) try: display_path = Path(build_path).relative_to(Path.cwd()) except ValueError: diff --git a/src/mscp/generate/mapping.py b/src/mscp/generate/mapping.py index 01d6259f1..c77606691 100644 --- a/src/mscp/generate/mapping.py +++ b/src/mscp/generate/mapping.py @@ -59,7 +59,7 @@ def generate_mapping(sp: Yaspin, args: argparse.Namespace) -> None: sp.spinner = Spinners.dots sp.text = "Collecting rule files" time.sleep(1) - rules: list[Macsecurityrule] = Macsecurityrule.collect_all_rules( + rules: list[Macsecurityrule] = Macsecurityrule.collect_platform_rules( args.os_name, args.os_version, tailoring=True ) diff --git a/src/mscp/generate/scap.py b/src/mscp/generate/scap.py index a4425cbce..61ba7bdc3 100644 --- a/src/mscp/generate/scap.py +++ b/src/mscp/generate/scap.py @@ -94,7 +94,7 @@ def generate_scap(sp: Yaspin, args: argparse.Namespace) -> None: sp.spinner = Spinners.dots sp.text = "Collecting rule files" output_file: Path = Path(config["output_dir"]) - all_rules: list[Macsecurityrule] = Macsecurityrule.collect_all_rules( + all_rules: list[Macsecurityrule] = Macsecurityrule.collect_platform_rules( args.os_name, args.os_version ) all_tags, benchmark_map = collect_tags_and_benchmarks(all_rules) diff --git a/src/mscp/generate/translation.py b/src/mscp/generate/translation.py index 55be59cdb..59d915cfc 100644 --- a/src/mscp/generate/translation.py +++ b/src/mscp/generate/translation.py @@ -97,7 +97,7 @@ def generate_localize_template(args: argparse.Namespace) -> None: context=f"template.{template_file.stem}.{ctr}", ) - rules: list[Macsecurityrule] = Macsecurityrule.collect_all_rules( + rules: list[Macsecurityrule] = Macsecurityrule.collect_platform_rules( args.os_name, args.os_version, tailoring=True )