diff --git a/docs/source/pages/tutorials/index.rst b/docs/source/pages/tutorials/index.rst index 6f6b3bf00..5fa5e899a 100644 --- a/docs/source/pages/tutorials/index.rst +++ b/docs/source/pages/tutorials/index.rst @@ -23,6 +23,7 @@ For the full list of supported technologies, such as CI services, registries, an detect_vulnerable_github_actions provenance detect_malicious_java_dep + verify_with_existing_policy generate_verification_summary_attestation use_verification_summary_attestation exclude_include_checks diff --git a/docs/source/pages/tutorials/verify_with_existing_policy.rst b/docs/source/pages/tutorials/verify_with_existing_policy.rst new file mode 100644 index 000000000..4a61b3f17 --- /dev/null +++ b/docs/source/pages/tutorials/verify_with_existing_policy.rst @@ -0,0 +1,86 @@ +=================================================================== +How to use the policy engine to verify with our predefined policies +=================================================================== + +This tutorial shows how to use the ``--existing-policy`` flag with the ``verify-policy`` subcommand to run one of the predefined policies that ship with Macaron. + +-------- +Use case +-------- + +Use ``--existing-policy`` when you want to run one of the built-in policies by name instead of providing a local policy file with ``--file``. Pre-defined policies are useful for quick checks or automated examples/tests. + +------- +Example +------- + +Run the ``malware-detection`` policy against a package URL: + +.. code-block:: shell + + ./run_macaron.sh analyze -purl pkg:pypi/django@5.0.6 + +.. note:: By default, Macaron clones the repositories and creates output files under the ``output`` directory. To understand the structure of this directory please see :ref:`Output Files Guide `. + +.. code-block:: shell + + ./run_macaron.sh verify-policy \ + --database output/macaron.db \ + --existing-policy malware-detection \ + --package-url "pkg:pypi/django@5.0.6" + +The result of this command should show that the policy succeeds with a zero exit code (if a policy fails to pass, Macaron returns a none-zero error code): + +.. code-block:: shell + + Components Satisfy Policy + 1 pkg:pypi/django@5.0.6 check-component + + Components Violate Policy None + + Passed Policies check-component + Failed Policies None + Policy Report output/policy_report.json + Verification Summary Attestation output/vsa.intoto.jsonl + Decode and Inspect the Content cat output/vsa.intoto.jsonl | jq -r '.payload' | base64 -d | jq + +Run the ``malware-detection`` policy using wildcard: + +.. code-block:: shell + + ./run_macaron.sh analyze -purl pkg:pypi/django@5.0.6 + ./run_macaron.sh analyze -purl pkg:pypi/django@1.11.1 + +.. note:: By default, Macaron clones the repositories and creates output files under the ``output`` directory. To understand the structure of this directory please see :ref:`Output Files Guide `. + +.. code-block:: shell + + ./run_macaron.sh verify-policy \ + --database output/macaron.db \ + --existing-policy malware-detection \ + --package-url "pkg:pypi/django@.*" + +It uses the wildcard '*' to checks for components satisfying the expression "pkg:pypi/django@.*". +The result of this command should show that the policy succeeds with a zero exit code (if a policy fails to pass, Macaron returns a none-zero error code): + +.. code-block:: shell + + Components Satisfy Policy + 1 pkg:pypi/django@5.0.6 check-component + 2 pkg:pypi/django@1.11.1 check-component + + Components Violate Policy None + + Passed Policies check-component + Failed Policies None + Policy Report output/policy_report.json + Verification Summary Attestation output/vsa.intoto.jsonl + Decode and Inspect the Content cat output/vsa.intoto.jsonl | jq -r '.payload' | base64 -d | jq + +----------------- +Related tutorials +----------------- + +- :doc:`detect_malicious_package` — shows what the malware-detection policy does in this tutorial. +- :doc:`use_verification_summary_attestation` — how to consume an attestation + produced by Macaron. diff --git a/src/macaron/__main__.py b/src/macaron/__main__.py index d972e9261..ec6f01e7d 100644 --- a/src/macaron/__main__.py +++ b/src/macaron/__main__.py @@ -196,7 +196,7 @@ def verify_policy(verify_policy_args: argparse.Namespace) -> int: int Returns os.EX_OK if successful or the corresponding error code on failure. """ - if not os.path.isfile(verify_policy_args.database): + if not verify_policy_args.list_policies and not os.path.isfile(verify_policy_args.database): logger.critical("The database file does not exist.") return os.EX_OSFILE @@ -204,6 +204,41 @@ def verify_policy(verify_policy_args: argparse.Namespace) -> int: show_prelude(verify_policy_args.database) return os.EX_OK + policy_content = None + if verify_policy_args.list_policies: + policy_dir = os.path.join(macaron.MACARON_PATH, "resources", "policies", "datalog") + policy_suffix = ".dl" + template_suffix = f"{policy_suffix}.template" + description_suffix = ".description" + + policies_with_desc: dict[str, str] = {} + try: + for policy_file in os.listdir(policy_dir): + if not policy_file.endswith(template_suffix): + continue + policy = os.path.splitext(policy_file)[0].replace(policy_suffix, "") + description_path = os.path.join(policy_dir, f"{policy}{description_suffix}") + try: + with open(description_path, encoding="utf-8") as f: + desc = f.read().strip() + if not desc: + desc = "No description available." + except OSError: + desc = "Could not read policy description." + policies_with_desc[policy] = desc + except FileNotFoundError: + logger.error("Policy directory %s not found.", policy_dir) + return os.EX_OSFILE + + policies_with_desc = dict(sorted(policies_with_desc.items())) + rich_handler = access_handler.get_handler() + rich_handler.set_available_policies(policies_with_desc) + + logger.info( + "Available policies are:\n%s", "\n".join(f"{name}\n{desc}\n" for name, desc in policies_with_desc.items()) + ) + return os.EX_OK + if verify_policy_args.file: if not os.path.isfile(verify_policy_args.file): logger.critical('The policy file "%s" does not exist.', verify_policy_args.file) @@ -211,7 +246,34 @@ def verify_policy(verify_policy_args: argparse.Namespace) -> int: with open(verify_policy_args.file, encoding="utf-8") as file: policy_content = file.read() - + elif verify_policy_args.existing_policy: + policy_dir = os.path.join(macaron.MACARON_PATH, "resources", "policies", "datalog") + policy_suffix = ".dl" + template_suffix = f"{policy_suffix}.template" + available_policies = [ + os.path.splitext(policy)[0].replace(policy_suffix, "") + for policy in os.listdir(policy_dir) + if policy.endswith(template_suffix) + ] + if verify_policy_args.existing_policy not in available_policies: + logger.error( + "The policy %s is not available. Available policies are: %s", + verify_policy_args.existing_policy, + available_policies, + ) + return os.EX_USAGE + policy_path = os.path.join(policy_dir, f"{verify_policy_args.existing_policy}{template_suffix}") + with open(policy_path, encoding="utf-8") as file: + policy_content = file.read() + try: + validation_package_url = verify_policy_args.package_url.replace("*", "") + PackageURL.from_string(validation_package_url) + policy_content = policy_content.replace("", verify_policy_args.package_url) + except ValueError as err: + logger.error("The package url %s is not valid. Error: %s", verify_policy_args.package_url, err) + return os.EX_USAGE + + if policy_content: result = run_policy_engine(verify_policy_args.database, policy_content) vsa = generate_vsa(policy_content=policy_content, policy_result=result) # Retrieve the console handler previously configured via the access_handler. @@ -316,6 +378,9 @@ def perform_action(action_args: argparse.Namespace) -> None: case "verify-policy": if not action_args.disable_rich_output: rich_handler.start("verify-policy") + if not action_args.list_policies and not action_args.database: + logger.error("macaron verify-policy: error: the following arguments are required: -d/--database") + sys.exit(os.EX_USAGE) sys.exit(verify_policy(action_args)) case "analyze": @@ -572,8 +637,11 @@ def main(argv: list[str] | None = None) -> None: vp_parser = sub_parser.add_parser(name="verify-policy") vp_group = vp_parser.add_mutually_exclusive_group(required=True) - vp_parser.add_argument("-d", "--database", required=True, type=str, help="Path to the database.") + vp_parser.add_argument("-d", "--database", type=str, help="Path to the database.") + vp_parser.add_argument("-purl", "--package-url", help="PackageURL for policy template.") vp_group.add_argument("-f", "--file", type=str, help="Path to the Datalog policy.") + vp_group.add_argument("-e", "--existing-policy", help="Name of the existing policy to run.") + vp_group.add_argument("-l", "--list-policies", action="store_true", help="List the existing policy to run.") vp_group.add_argument("-s", "--show-prelude", action="store_true", help="Show policy prelude.") # Find the repo and commit of a passed PURL, or the commit of a passed PURL and repo. diff --git a/src/macaron/console.py b/src/macaron/console.py index eedaae962..10a624dd4 100644 --- a/src/macaron/console.py +++ b/src/macaron/console.py @@ -76,6 +76,19 @@ def _make_reports_table(reports: dict) -> Table: table.add_row(report_type, report_path, style="blue") return table + @staticmethod + def _make_policies_table(policies: dict[str, str]) -> Table: + """Build a two-column table of policy name and a short description.""" + table = Table(box=None) + table.add_column("[blue]Policy[/]", justify="left", style="blue bold") + table.add_column("Description", justify="left") + total_policies = len(policies) + for i, (name, desc) in enumerate(policies.items()): + table.add_row(name, desc) + if i < total_policies - 1: + table.add_row() + return table + class Dependency(TableBuilder): """A class to manage the display of dependency analysis in the console.""" @@ -289,6 +302,8 @@ def __init__(self, *args: Any, verbose: bool = False, **kwargs: Any) -> None: self.if_dependency: bool = False self.dependency_analysis_map: dict[str, int] = {} self.dependency_analysis_list: list[Dependency] = [] + self.available_policies: dict[str, str] = {} + self.policies_table = Table(box=None) self.components_violates_table = Table(box=None) self.components_satisfy_table = Table(box=None) self.policy_summary_table = Table(show_header=False, box=None) @@ -484,6 +499,18 @@ def update_policy_report(self, report_path: str) -> None: self.policy_summary["Policy Report"] = report_path self.generate_policy_summary_table() + def set_available_policies(self, policies: dict[str, str]) -> None: + """ + Store available policies and build the policies table. + + Parameters + ---------- + policies : dict[str, str] + Mapping of policy name to short description. + """ + self.available_policies = policies + self.policies_table = self._make_policies_table(self.available_policies) + def update_vsa(self, vsa_path: str) -> None: """ Update the verification summary attestation path. @@ -705,7 +732,9 @@ def make_layout(self) -> Group: self.report_table, ] elif self.command == "verify-policy": - if self.policy_summary_table.row_count > 0: + if self.policies_table.row_count > 0: + layout = layout + [self.policies_table] + elif self.policy_summary_table.row_count > 0: if self.components_satisfy_table.row_count > 0: layout = layout + [ "[bold green] Components Satisfy Policy[/]", diff --git a/src/macaron/resources/policies/README.md b/src/macaron/resources/policies/README.md new file mode 100644 index 000000000..b086526ad --- /dev/null +++ b/src/macaron/resources/policies/README.md @@ -0,0 +1,12 @@ +Policies +======= + +This directory contains policy resources used by Macaron. Policies in this folder are packaged as templates that the verify-policy command can use. + +Common files and conventions +--------------------------- +- `*.dl.template` - datalog policy templates. +- `*.description` - short descriptions that explain the policy's intent. +- `*.cue.template` - CUE-based expectation templates used by the GDK. + +Example policies are exposed to the user via Macaron commands `verify-policy --existing-policy `. diff --git a/src/macaron/resources/policies/datalog/README.md b/src/macaron/resources/policies/datalog/README.md new file mode 100644 index 000000000..43a9f6450 --- /dev/null +++ b/src/macaron/resources/policies/datalog/README.md @@ -0,0 +1,13 @@ +Datalog policy templates +========================= + +- This folder contains Datalog-based policy templates and accompanying `.description` files used by Macaron's policy engine. + +- These `.dl.template` templates are intended as examples and starting points. They can be used by name using `--existing-policy` flag. + +- `*.description` - descriptions for each template. These are intended to be shown in UIs or documentation to help users choose an appropriate example policy. + +Extending or adding templates +----------------------------- +- Add a new `.dl.template` file and a matching `.description` file. +- Update documentation or the tutorials page if you add new example policies that should be exposed to users. diff --git a/src/macaron/resources/policies/datalog/check-github-actions.description b/src/macaron/resources/policies/datalog/check-github-actions.description new file mode 100644 index 000000000..65292bbd8 --- /dev/null +++ b/src/macaron/resources/policies/datalog/check-github-actions.description @@ -0,0 +1 @@ +Detects whether a component was built using GitHub Actions that are known to be vulnerable or otherwise unsafe. The policy evaluates a check named `mcn_githubactions_vulnerabilities_1` and reports a passed/failed result for the component when applied. diff --git a/src/macaron/resources/policies/datalog/check-github-actions.dl.template b/src/macaron/resources/policies/datalog/check-github-actions.dl.template new file mode 100644 index 000000000..08f456e81 --- /dev/null +++ b/src/macaron/resources/policies/datalog/check-github-actions.dl.template @@ -0,0 +1,8 @@ +#include "prelude.dl" + +Policy("github_actions_vulns", component_id, "GitHub Actions Vulnerability Detection") :- + check_passed(component_id, "mcn_githubactions_vulnerabilities_1"). + +apply_policy_to("github_actions_vulns", component_id) :- + is_component(component_id, purl), + match("", purl). diff --git a/src/macaron/resources/policies/datalog/malware-detection-dependencies.description b/src/macaron/resources/policies/datalog/malware-detection-dependencies.description new file mode 100644 index 000000000..544cdccfc --- /dev/null +++ b/src/macaron/resources/policies/datalog/malware-detection-dependencies.description @@ -0,0 +1 @@ +Checks the component and its transitive dependencies for indicators of malicious or suspicious content. The policy ensures the component and each dependency pass the `mcn_detect_malicious_metadata_1` check. diff --git a/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template b/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template new file mode 100644 index 000000000..54b83bf27 --- /dev/null +++ b/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template @@ -0,0 +1,10 @@ +#include "prelude.dl" + +Policy("check-dependencies", component_id, "Check the dependencies of component.") :- + transitive_dependency(component_id, dependency), + check_passed(component_id, "mcn_detect_malicious_metadata_1"), + check_passed(dependency, "mcn_detect_malicious_metadata_1"). + +apply_policy_to("check-dependencies", component_id) :- + is_component(component_id, purl), + match("", purl). diff --git a/src/macaron/resources/policies/datalog/malware-detection.description b/src/macaron/resources/policies/datalog/malware-detection.description new file mode 100644 index 000000000..0bd04a45f --- /dev/null +++ b/src/macaron/resources/policies/datalog/malware-detection.description @@ -0,0 +1 @@ +Checks a component for indicators of malicious or suspicious content. The policy evaluates a check named mcn_detect_malicious_metadata_1 and reports a passed/failed result for the component when applied. diff --git a/src/macaron/resources/policies/datalog/malware-detection.dl.template b/src/macaron/resources/policies/datalog/malware-detection.dl.template new file mode 100644 index 000000000..77eedc5cf --- /dev/null +++ b/src/macaron/resources/policies/datalog/malware-detection.dl.template @@ -0,0 +1,9 @@ +#include "prelude.dl" + +Policy("check-component", component_id, "Check component artifacts.") :- + check_passed(component_id, "mcn_detect_malicious_metadata_1"). + + +apply_policy_to("check-component", component_id) :- + is_component(component_id, purl), + match("", purl). diff --git a/tests/policy_engine/test_datalog_description.py b/tests/policy_engine/test_datalog_description.py new file mode 100644 index 000000000..d0450a2c4 --- /dev/null +++ b/tests/policy_engine/test_datalog_description.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""Tests that every Datalog template has a matching .description file.""" + +from pathlib import Path + +import macaron + + +def test_datalog_templates_have_descriptions() -> None: + """Verify each ``*.dl.template`` has a corresponding ``*.description``.""" + datalog_dir = Path(macaron.__file__).resolve().parent.joinpath("resources", "policies", "datalog") + templates = sorted(datalog_dir.glob("*.dl.template")) + + missing = [] + for tmpl in templates: + expected_desc = datalog_dir.joinpath(tmpl.name.replace(".dl.template", ".description")) + if not expected_desc.exists(): + missing.append((tmpl.name, expected_desc)) + + if templates and missing: + missing_list = ", ".join(f"{t} -> {d}" for t, d in missing) + raise AssertionError("Missing .description files for the following templates: " + missing_list) diff --git a/tests/policy_engine/test_existing_policy.py b/tests/policy_engine/test_existing_policy.py new file mode 100644 index 000000000..4d8a45af6 --- /dev/null +++ b/tests/policy_engine/test_existing_policy.py @@ -0,0 +1,65 @@ +# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""This module tests the existing-policy flag supported by the policy engine.""" + +import argparse +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +from macaron.__main__ import verify_policy + + +def test_verify_existing_policy_success(tmp_path: Path) -> None: + """When an existing policy is provided and package-url is valid, verify_policy returns EX_OK.""" + db_file = os.path.join(tmp_path, "macaron.db") + with open(db_file, "w", encoding="utf-8") as f: + f.write("") + + # Use a MagicMock for the handler. + mock_handler = MagicMock() + + # Fake run_policy_engine and generate_vsa that returns a fixed result. + fake_run = MagicMock(return_value={"passed_policies": [["check-component"]], "failed_policies": []}) + fake_generate_vsa = MagicMock(return_value=None) + + # Fake PolicyReporter class: when called, returns an instance with generate method. + fake_policy_reporter_cls = MagicMock() + fake_policy_reporter_inst = MagicMock() + fake_policy_reporter_inst.generate.return_value = None + fake_policy_reporter_cls.return_value = fake_policy_reporter_inst + + with ( + patch("macaron.__main__.run_policy_engine", fake_run), + patch("macaron.__main__.generate_vsa", fake_generate_vsa), + patch("macaron.__main__.access_handler.get_handler", return_value=mock_handler), + patch("macaron.__main__.PolicyReporter", fake_policy_reporter_cls), + ): + policy_args = argparse.Namespace( + database=str(db_file), + show_prelude=False, + file=None, + list_policies=False, + existing_policy="malware-detection", + package_url="pkg:pypi/django", + ) + result = verify_policy(policy_args) + assert result == os.EX_OK + + +def test_verify_existing_policy_not_found(tmp_path: Path) -> None: + """Requesting a non-existent policy returns usage error.""" + db_file = os.path.join(tmp_path, "macaron.db") + with open(db_file, "w", encoding="utf-8") as f: + f.write("") + policy_args = argparse.Namespace( + database=str(db_file), + show_prelude=False, + file=None, + list_policies=False, + existing_policy="no-such-policy", + package_url="pkg:pypi/django", + ) + result = verify_policy(policy_args) + assert result == os.EX_USAGE