diff --git a/backend/engine/plugins/lib/trivy_common/generate_composer_locks.py b/backend/engine/plugins/lib/trivy_common/generate_composer_locks.py new file mode 100644 index 000000000..6b816c620 --- /dev/null +++ b/backend/engine/plugins/lib/trivy_common/generate_composer_locks.py @@ -0,0 +1,101 @@ +import os +from glob import glob +from engine.plugins.lib import utils +import docker +import uuid +from typing import Optional + +logger = utils.setup_logging("trivy_sca") +docker_client = docker.from_env() + + +def install_package_files(path: str, include_dev: bool, sub_path: str, working_src: str, root_path: str): + # sub_path: absolute path to the composer project inside the parent container (e.g. /tmp/work/foo/bar) + # temp_vol_name: Docker volume name (e.g. artemis-plugin-temp-xxxx) + # temp_vol_mount: mount path inside the plugin container (e.g. /tmp/work) + # root_path: the original root for logging + + rel_subdir = os.path.relpath(sub_path, path) + abs_path_in_container = os.path.join("/app", rel_subdir) + logger.info(f"Mounting volume: {working_src} to /app in composer container") + logger.info(f"Target subdir in container: {abs_path_in_container}") + logger.info(f"composer.json: {os.path.join(sub_path, 'composer.json')}") + logger.info(f"composer.json exists: {os.path.exists(os.path.join(sub_path, 'composer.json'))}") + + composer_cmd = ( + "composer --version && " + "ls -l && " + "cat composer.json && " + "composer install --no-scripts -q" + " && ls -l composer.lock && ls -l" + ) + + # if not include_dev: + # composer_cmd += " --no-dev" + + COMPOSER_IMG = "composer:2.8.11" + container_name = f"composer_runner_{uuid.uuid4().hex[:8]}" + container_mount_path = "/app" + + try: + container = docker_client.containers.run( + COMPOSER_IMG, + name=container_name, + command=["sh", "-c", composer_cmd], + volumes={ + working_src: {"bind": container_mount_path, "mode": "rw"}, + }, + working_dir=abs_path_in_container, + auto_remove=False, + stdout=True, + stderr=True, + detach=True, + ) + + result = container.wait() + logs = container.logs(stdout=True, stderr=True).decode("utf-8") + logger.info(f"Container logs for {sub_path.replace(root_path, '')}:\n{logs}") + logger.info(f"Container exit code: {result.get('StatusCode')}") + container.remove() + except Exception as e: + logger.error(f"Error running composer install in Docker: {e}") + + # Check if composer.lock was created + lockfile = os.path.join(sub_path, "composer.lock") + if not os.path.exists(lockfile): + logger.error(f"composer.lock was not created in {sub_path}") + + return + + +def check_composer_package_files( + path: str, working_src: str, include_dev: bool, root_path: Optional[str] = None +) -> tuple: + """ + Find all composer.json files in the repo and build lock files for them if missing. + """ + errors = [] + alerts = [] + logger.info("Searching %s for composer files", path) + files = glob(f"{path}/**/composer.json", recursive=True) + logger.info("Found %d composer.json files", len(files)) + + if len(files) == 0: + return errors, alerts + + paths = set() + for filename in files: + paths.add(os.path.dirname(filename)) + + for sub_path in paths: + lockfile = os.path.join(sub_path, "composer.lock") + lockfile_missing = not os.path.exists(lockfile) + if lockfile_missing: + msg = ( + f"No composer.lock file was found in path {sub_path.replace(path, '')}." + " Please consider creating a composer.lock file for this project." + ) + logger.warning(msg) + alerts.append(msg) + install_package_files(path, include_dev, sub_path, working_src, root_path or working_src) + return errors, alerts diff --git a/backend/engine/plugins/lib/trivy_common/generate_locks.py b/backend/engine/plugins/lib/trivy_common/generate_npm_locks.py similarity index 100% rename from backend/engine/plugins/lib/trivy_common/generate_locks.py rename to backend/engine/plugins/lib/trivy_common/generate_npm_locks.py diff --git a/backend/engine/plugins/trivy_sbom/main.py b/backend/engine/plugins/trivy_sbom/main.py index 93f331a4b..5471571ee 100644 --- a/backend/engine/plugins/trivy_sbom/main.py +++ b/backend/engine/plugins/trivy_sbom/main.py @@ -5,7 +5,7 @@ import json import subprocess from typing import Optional -from engine.plugins.lib.trivy_common.generate_locks import check_package_files +from engine.plugins.lib.trivy_common.generate_npm_locks import check_package_files from engine.plugins.lib.sbom_common.go_installer import go_mod_download from engine.plugins.trivy_sbom.parser import clean_output_application_sbom from engine.plugins.trivy_sbom.parser import edit_application_sbom_path diff --git a/backend/engine/plugins/trivy_sca/main.py b/backend/engine/plugins/trivy_sca/main.py index a978f7100..fdcb91a06 100644 --- a/backend/engine/plugins/trivy_sca/main.py +++ b/backend/engine/plugins/trivy_sca/main.py @@ -2,9 +2,12 @@ trivy SCA plugin """ +from os.path import abspath + import json import subprocess -from engine.plugins.lib.trivy_common.generate_locks import check_package_files +from engine.plugins.lib.trivy_common.generate_npm_locks import check_package_files +from engine.plugins.lib.trivy_common.generate_composer_locks import check_composer_package_files from engine.plugins.lib.utils import convert_string_to_json from engine.plugins.lib.trivy_common.parsing_util import parse_output from engine.plugins.lib.utils import setup_logging @@ -36,11 +39,25 @@ def execute_trivy_lock_scan(path: str, include_dev: bool): def main(): logger.info("Executing Trivy SCA") args = parse_args() + path = abspath(args.path) include_dev = args.engine_vars.get("include_dev", False) results = [] + alerts = [] + errors = [] + + # Generate Lock files (and install npm packages for license info) + lock_file_errors, lock_file_alerts = check_package_files(args.path, include_dev, True) + alerts.extend(lock_file_alerts) + errors.extend(lock_file_errors) + + # Run Composer Install for exact version numbers + (working_src, working_mount) = str(args.engine_vars.get("working_mount", "")).split(":") + if not working_src or not working_mount: + errors.append("Working volume not provided") - # Generate Lock files (without installing npm packages) - lock_file_errors, lock_file_alerts = check_package_files(args.path, include_dev, False) + compose_lock_errors, compose_lock_alerts = check_composer_package_files(path, working_src, include_dev) + alerts.extend(compose_lock_alerts) + errors.extend(compose_lock_errors) # Scan local lock files output = execute_trivy_lock_scan(args.path, include_dev) @@ -54,11 +71,7 @@ def main(): results.extend(result) # Return results - print( - json.dumps( - {"success": not bool(results), "details": results, "errors": lock_file_errors, "alerts": lock_file_alerts} - ) - ) + print(json.dumps({"success": not bool(results), "details": results, "errors": errors, "alerts": alerts})) if __name__ == "__main__": diff --git a/backend/engine/plugins/trivy_sca/settings.json b/backend/engine/plugins/trivy_sca/settings.json index e20df1b3a..f477fac3c 100644 --- a/backend/engine/plugins/trivy_sca/settings.json +++ b/backend/engine/plugins/trivy_sca/settings.json @@ -2,6 +2,7 @@ "name": "Trivy SCA Scanner", "type": "vulnerability", "image": "$ECR/artemis/dind:latest", + "docker": true, "build_images": false, "enabled": true, "writable": true diff --git a/backend/engine/tests/test_plugin_trivy_sca.py b/backend/engine/tests/test_plugin_trivy_sca.py index 5811c1f46..f4dcc9d5d 100644 --- a/backend/engine/tests/test_plugin_trivy_sca.py +++ b/backend/engine/tests/test_plugin_trivy_sca.py @@ -7,7 +7,8 @@ from unittest.mock import patch from oci import remover from engine.plugins.trivy_sca import main as Trivy -from engine.plugins.lib.trivy_common.generate_locks import check_package_files +from engine.plugins.lib.trivy_common.generate_npm_locks import check_package_files +from engine.plugins.lib.trivy_common.generate_composer_locks import check_composer_package_files from engine.plugins.lib.utils import convert_string_to_json from engine.plugins.lib.utils import setup_logging @@ -122,6 +123,16 @@ def test_lock_file_exists(self): actual = check_package_files("/mocked/path/", False, False) self.assertEqual(len(actual[1]), 0, "There should NOT be a warning of a lock file missing") + def test_compose_lock_file_exists(self): + with patch(f"{GENERATE_LOCKS_PREFIX}glob") as mock_glob: + mock_glob.return_value = ["/mocked/path/compose.json"] + with patch(f"{GENERATE_LOCKS_PREFIX}os.path.exists", return_value=True): + with patch(f"{GENERATE_LOCKS_PREFIX}subprocess.run") as mock_proc: + mock_proc.stderr = mock_proc.stdout = None + mock_proc.return_value = CompletedProcess(args="", returncode=0) + actual = check_composer_package_files("/mocked/path/", False) + self.assertEqual(len(actual[1]), 0, "There should NOT be a warning of a lock file missing") + def test_lock_file_missing(self): with patch(f"{GENERATE_LOCKS_PREFIX}glob") as mock_glob: mock_glob.return_value = ["/mocked/path/package.json"] @@ -133,6 +144,16 @@ def test_lock_file_missing(self): actual = check_package_files("/mocked/path/", False, False) self.assertEqual(len(actual[1]), 1, "There should be a warning of a lock file missing") + def test_compose_lock_file_missing(self): + with patch(f"{GENERATE_LOCKS_PREFIX}glob") as mock_glob: + mock_glob.return_value = ["/mocked/path/compose.json"] + with patch(f"{GENERATE_LOCKS_PREFIX}os.path.exists", return_value=False): + with patch(f"{GENERATE_LOCKS_PREFIX}subprocess.run") as mock_proc: + mock_proc.stderr = mock_proc.stdout = None + mock_proc.return_value = CompletedProcess(args="", returncode=0) + actual = check_composer_package_files("/mocked/path/", False) + self.assertEqual(len(actual[1]), 1, "There should be a warning of a lock file missing") + def test_check_output(self): check_output_list = Trivy.parse_output(self.demo_results_dict) self.assertIn(TEST_CHECK_OUTPUT_PACKAGE_LOCK, check_output_list)