diff --git a/src/macaron/config/defaults.ini b/src/macaron/config/defaults.ini index f895c20aa..704ae2d43 100644 --- a/src/macaron/config/defaults.ini +++ b/src/macaron/config/defaults.ini @@ -159,8 +159,6 @@ jenkins = withMaven buildPlugin asfMavenTlpStdBuild - ./mvnw - ./mvn [builder.maven.ci.deploy] github_actions = @@ -233,14 +231,12 @@ wrapper_files = [builder.gradle.ci.build] github_actions = actions/setup-java travis_ci = - jdk - ./gradlew + gradle circle_ci = - ./gradlew + gradle gitlab_ci = - ./gradlew + gradle jenkins = - ./gradlew [builder.gradle.ci.deploy] github_actions = @@ -248,24 +244,28 @@ github_actions = spring-io/artifactory-deploy-action travis_ci = artifactoryPublish + gradle publish ./gradlew publish publishToSonatype gradle-git-publish gitPublishPush circle_ci = artifactoryPublish + gradle publish ./gradlew publish publishToSonatype gradle-git-publish gitPublishPush gitlab_ci = artifactoryPublish + gradle publish ./gradlew publish publishToSonatype gradle-git-publish gitPublishPush jenkins = artifactoryPublish + gradle publish ./gradlew publish publishToSonatype gradle-git-publish diff --git a/src/macaron/slsa_analyzer/analyzer.py b/src/macaron/slsa_analyzer/analyzer.py index 894c82134..292898bf6 100644 --- a/src/macaron/slsa_analyzer/analyzer.py +++ b/src/macaron/slsa_analyzer/analyzer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 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 handles the cloning and analyzing a Git repo.""" @@ -934,10 +934,7 @@ def _determine_ci_services(self, analyze_ctx: AnalyzeContext, git_service: BaseG # Parse configuration files and generate IRs. # Add the bash commands to the context object to be used by other checks. - callgraph = ci_service.build_call_graph( - analyze_ctx.component.repository.fs_path, - os.path.relpath(analyze_ctx.component.repository.fs_path, analyze_ctx.output_dir), - ) + callgraph = ci_service.build_call_graph(analyze_ctx.component.repository.fs_path) analyze_ctx.dynamic_data["ci_services"].append( CIInfo( service=ci_service, diff --git a/src/macaron/slsa_analyzer/build_tool/base_build_tool.py b/src/macaron/slsa_analyzer/build_tool/base_build_tool.py index db0fff3cb..24c53c5c4 100644 --- a/src/macaron/slsa_analyzer/build_tool/base_build_tool.py +++ b/src/macaron/slsa_analyzer/build_tool/base_build_tool.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 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 contains the BaseBuildTool class to be inherited by other specific Build Tools.""" @@ -44,7 +44,7 @@ class BuildToolCommand(TypedDict): ci_path: str #: The CI step object that calls the command. - step_node: BaseNode + step_node: BaseNode | None #: The list of name of reachable variables that contain secrets.""" reachable_secrets: list[str] diff --git a/src/macaron/slsa_analyzer/checks/build_as_code_check.py b/src/macaron/slsa_analyzer/checks/build_as_code_check.py index df00ef2b3..e6e0f8ed9 100644 --- a/src/macaron/slsa_analyzer/checks/build_as_code_check.py +++ b/src/macaron/slsa_analyzer/checks/build_as_code_check.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 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 contains the BuildAsCodeCheck class.""" @@ -27,7 +27,6 @@ GitHubWorkflowType, ) from macaron.slsa_analyzer.ci_service.gitlab_ci import GitLabCI -from macaron.slsa_analyzer.ci_service.jenkins import Jenkins from macaron.slsa_analyzer.ci_service.travis import Travis from macaron.slsa_analyzer.registry import registry from macaron.slsa_analyzer.slsa_req import ReqName @@ -262,10 +261,11 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: trigger_link=trigger_link, job_id=( build_command["step_node"].caller.name - if isinstance(build_command["step_node"].caller, GitHubJobNode) + if build_command["step_node"] + and isinstance(build_command["step_node"].caller, GitHubJobNode) else None ), - step_id=build_command["step_node"].node_id, + step_id=build_command["step_node"].node_id if build_command["step_node"] else None, step_name=( build_command["step_node"].name if isinstance(build_command["step_node"], BashNode) @@ -299,7 +299,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: # We currently don't parse these CI configuration files. # We just look for a keyword for now. - for unparsed_ci in (Jenkins, Travis, CircleCI, GitLabCI): + for unparsed_ci in (Travis, CircleCI, GitLabCI): if isinstance(ci_service, unparsed_ci): if tool.ci_deploy_kws[ci_service.name]: deploy_kw, config_name = ci_service.has_kws_in_config( diff --git a/src/macaron/slsa_analyzer/checks/build_service_check.py b/src/macaron/slsa_analyzer/checks/build_service_check.py index abbef2f35..cea689a7c 100644 --- a/src/macaron/slsa_analyzer/checks/build_service_check.py +++ b/src/macaron/slsa_analyzer/checks/build_service_check.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 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 contains the BuildServiceCheck class.""" @@ -18,7 +18,6 @@ from macaron.slsa_analyzer.ci_service.base_ci_service import BaseCIService, NoneCIService from macaron.slsa_analyzer.ci_service.circleci import CircleCI from macaron.slsa_analyzer.ci_service.gitlab_ci import GitLabCI -from macaron.slsa_analyzer.ci_service.jenkins import Jenkins from macaron.slsa_analyzer.ci_service.travis import Travis from macaron.slsa_analyzer.registry import registry from macaron.slsa_analyzer.slsa_req import ReqName @@ -170,7 +169,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: # We currently don't parse these CI configuration files. # We just look for a keyword for now. - for unparsed_ci in (Jenkins, Travis, CircleCI, GitLabCI): + for unparsed_ci in (Travis, CircleCI, GitLabCI): if isinstance(ci_service, unparsed_ci): if tool.ci_build_kws[ci_service.name]: build_kw, config_name = ci_service.has_kws_in_config( diff --git a/src/macaron/slsa_analyzer/checks/infer_artifact_pipeline_check.py b/src/macaron/slsa_analyzer/checks/infer_artifact_pipeline_check.py index 8902d6ef2..d3421d222 100644 --- a/src/macaron/slsa_analyzer/checks/infer_artifact_pipeline_check.py +++ b/src/macaron/slsa_analyzer/checks/infer_artifact_pipeline_check.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2023 - 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 contains the InferArtifactPipelineCheck class to check if an artifact is published from a pipeline automatically.""" @@ -194,7 +194,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: # Obtain the job and step calling the deploy command. # This data must have been found already by the build-as-code check. build_predicate = ci_info["build_info_results"].statement["predicate"] - if build_predicate is None: + if build_predicate is None or build_predicate["buildType"] != f"Custom {ci_service.name}": continue build_entry_point = json_extract(build_predicate, ["invocation", "configSource", "entryPoint"], str) diff --git a/src/macaron/slsa_analyzer/ci_service/jenkins.py b/src/macaron/slsa_analyzer/ci_service/jenkins.py index c43354884..cd256cae8 100644 --- a/src/macaron/slsa_analyzer/ci_service/jenkins.py +++ b/src/macaron/slsa_analyzer/ci_service/jenkins.py @@ -1,12 +1,27 @@ -# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 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 analyzes Jenkins CI.""" +import glob +import logging +import os +import re +from collections.abc import Iterable +from enum import Enum +from typing import Any + from macaron.code_analyzer.call_graph import BaseNode, CallGraph from macaron.config.defaults import defaults +from macaron.config.global_config import global_config +from macaron.errors import ParseError +from macaron.parsers import bashparser +from macaron.repo_verifier.repo_verifier import BaseBuildTool +from macaron.slsa_analyzer.build_tool.base_build_tool import BuildToolCommand from macaron.slsa_analyzer.ci_service.base_ci_service import BaseCIService +logger: logging.Logger = logging.getLogger(__name__) + class Jenkins(BaseCIService): """This class implements Jenkins CI service.""" @@ -29,7 +44,17 @@ def get_workflows(self, repo_path: str) -> list: list The list of workflow files in this repository. """ - return [] + if not self.is_detected(repo_path=repo_path): + logger.debug("There are no Jenkinsfile configurations.") + return [] + + workflow_files = [] + for conf in self.entry_conf: + workflows = glob.glob(os.path.join(repo_path, conf)) + if workflows: + logger.debug("Found Jenkinsfile configuration.") + workflow_files.extend(workflows) + return workflow_files def load_defaults(self) -> None: """Load the default values from defaults.ini.""" @@ -56,7 +81,111 @@ def build_call_graph(self, repo_path: str, macaron_path: str = "") -> CallGraph: CallGraph : CallGraph The call graph built for the CI. """ - return CallGraph(BaseNode(), "") + if not macaron_path: + macaron_path = global_config.macaron_path + + root: BaseNode = BaseNode() + call_graph = CallGraph(root, repo_path) + + # To match lines that start with sh '' or sh ''' ''' (either single or triple quotes) + # we need to account for both the single-line and multi-line cases. + pattern = r"^\s*sh\s+'{1,3}(.*?)'{1,3}$" + workflow_files = self.get_workflows(repo_path) + + for workflow_path in workflow_files: + try: + with open(workflow_path, encoding="utf-8") as wf: + lines = wf.readlines() + except OSError as error: + logger.debug("Unable to read Jenkinsfile %s: %s", workflow_path, error) + return call_graph + + # Add internal workflow. + workflow_name = os.path.basename(workflow_path) + workflow_node = JenkinsNode( + name=workflow_name, + node_type=JenkinsNodeType.INTERNAL, + source_path=workflow_path, + caller=root, + ) + root.add_callee(workflow_node) + + # Find matching lines. + for line in lines: + match = re.match(pattern, line) + if not match: + continue + + try: + parsed_bash_script = bashparser.parse(match.group(1), macaron_path=macaron_path) + except ParseError as error: + logger.debug(error) + continue + + # TODO: Similar to GitHub Actions, we should enable support for recursive calls to bash scripts + # within Jenkinsfiles. While the implementation should be relatively straightforward, it’s + # recommended to first refactor the bashparser to make it agnostic to GitHub Actions. + bash_node = bashparser.BashNode( + "jenkins_inline_cmd", + bashparser.BashScriptType.INLINE, + workflow_path, + parsed_step_obj=None, + parsed_bash_obj=parsed_bash_script, + node_id=None, + caller=workflow_node, + ) + workflow_node.add_callee(bash_node) + + return call_graph + + def get_build_tool_commands(self, callgraph: CallGraph, build_tool: BaseBuildTool) -> Iterable[BuildToolCommand]: + """ + Traverse the callgraph and find all the reachable build tool commands. + + Parameters + ---------- + callgraph: CallGraph + The callgraph reachable from the CI workflows. + build_tool: BaseBuildTool + The corresponding build tool for which shell commands need to be detected. + + Yields + ------ + BuildToolCommand + The object that contains the build command as well useful contextual information. + + Raises + ------ + CallGraphError + Error raised when an error occurs while traversing the callgraph. + """ + yield from sorted( + self._get_build_tool_commands(callgraph=callgraph, build_tool=build_tool), + key=str, + ) + + def _get_build_tool_commands(self, callgraph: CallGraph, build_tool: BaseBuildTool) -> Iterable[BuildToolCommand]: + """Traverse the callgraph and find all the reachable build tool commands.""" + for node in callgraph.bfs(): + # We are just interested in nodes that have bash commands. + if isinstance(node, bashparser.BashNode): + # The Jenkins configuration that triggers the path in the callgraph. + workflow_node = node.caller + + # Find the bash commands that call the build tool. + for cmd in node.parsed_bash_obj.get("commands", []): + if build_tool.is_build_command(cmd): + yield BuildToolCommand( + ci_path=workflow_node.source_path if workflow_node else "", + command=cmd, + step_node=None, + language=build_tool.language, + language_versions=None, + language_distributions=None, + language_url=None, + reachable_secrets=[], + events=None, + ) def has_latest_run_passed( self, repo_full_name: str, branch_name: str | None, commit_sha: str, commit_date: str, workflow: str @@ -85,3 +214,41 @@ def has_latest_run_passed( The feed back of the check, or empty if no passing workflow is found. """ return "" + + +class JenkinsNodeType(str, Enum): + """This class represents Jenkins node type.""" + + INTERNAL = "internal" # Configurations declared in one file. + + +class JenkinsNode(BaseNode): + """This class represents a callgraph node for Jenkinsfile configuration.""" + + def __init__( + self, + name: str, + node_type: JenkinsNodeType, + source_path: str, + **kwargs: Any, + ) -> None: + """Initialize instance. + + Parameters + ---------- + name : str + Name of the workflow. + node_type : JenkinsNodeType + The type of node. + source_path : str + The path of the workflow. + caller: BaseNode | None + The caller node. + """ + super().__init__(**kwargs) + self.name = name + self.node_type: JenkinsNodeType = node_type + self.source_path = source_path + + def __str__(self) -> str: + return f"JenkinsNodeType({self.name},{self.node_type})" diff --git a/tests/integration/cases/org_apache_dubbo_dubbo-rpc-memcached/policy.dl b/tests/integration/cases/org_apache_dubbo_dubbo-rpc-memcached/policy.dl new file mode 100644 index 000000000..636029681 --- /dev/null +++ b/tests/integration/cases/org_apache_dubbo_dubbo-rpc-memcached/policy.dl @@ -0,0 +1,36 @@ +/* Copyright (c) 2024 - 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/. */ + +#include "prelude.dl" + +Policy("test_policy", component_id, "") :- + check_passed(component_id, "mcn_version_control_system_1"), + check_passed(component_id, "mcn_scm_authenticity_1"), + check_passed(component_id, "mcn_build_tool_1"), + check_passed(component_id, "mcn_build_script_1"), + check_passed(component_id, "mcn_build_service_1"), + build_service_check( + bs_check_id, + "maven", + "jenkins", + _, + "[\"./mvnw\", \"clean\", \"install\", \"-pl\", \"\\\"dubbo-dependencies-bom\\\"\"]", + "java", + _, + _, + _ + ), + check_facts(bs_check_id, _, component_id,_,_), + check_failed(component_id, "mcn_build_as_code_1"), + check_failed(component_id, "mcn_find_artifact_pipeline_1"), + check_failed(component_id, "mcn_provenance_available_1"), + check_failed(component_id, "mcn_provenance_derived_commit_1"), + check_failed(component_id, "mcn_provenance_derived_repo_1"), + check_failed(component_id, "mcn_provenance_expectation_1"), + check_failed(component_id, "mcn_provenance_level_three_1"), + check_failed(component_id, "mcn_provenance_witness_level_one_1"), + check_failed(component_id, "mcn_trusted_builder_level_three_1"), + is_repo_url(component_id, "https://github.com/apache/dubbo"). + +apply_policy_to("test_policy", component_id) :- + is_component(component_id, "pkg:maven/org.apache.dubbo/dubbo-rpc-memcached@2.7.7"). diff --git a/tests/integration/cases/org_apache_dubbo_dubbo-rpc-memcached/test.yaml b/tests/integration/cases/org_apache_dubbo_dubbo-rpc-memcached/test.yaml new file mode 100644 index 000000000..9b4ff9230 --- /dev/null +++ b/tests/integration/cases/org_apache_dubbo_dubbo-rpc-memcached/test.yaml @@ -0,0 +1,21 @@ +# Copyright (c) 2024 - 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/. + +description: | + Analyzing with PURL and repository path without dependency resolution. + +tags: +- macaron-python-package +- tutorial + +steps: +- name: Run macaron analyze + kind: analyze + options: + command_args: + - -purl + - pkg:maven/org.apache.dubbo/dubbo-rpc-memcached@2.7.7 +- name: Run macaron verify-policy to verify passed/failed checks + kind: verify + options: + policy: policy.dl