Skip to content

Commit

Permalink
feat: add callgraph and build cmd detection for Jenkins
Browse files Browse the repository at this point in the history
Signed-off-by: behnazh-w <behnaz.hassanshahi@oracle.com>
  • Loading branch information
behnazh-w committed Feb 6, 2025
1 parent 85d9f01 commit dbe2525
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 27 deletions.
14 changes: 7 additions & 7 deletions src/macaron/config/defaults.ini
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,6 @@ jenkins =
withMaven
buildPlugin
asfMavenTlpStdBuild
./mvnw
./mvn

[builder.maven.ci.deploy]
github_actions =
Expand Down Expand Up @@ -233,39 +231,41 @@ 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 =
# This action can be used to deploy artifacts to a JFrog artifactory server.
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
Expand Down
7 changes: 2 additions & 5 deletions src/macaron/slsa_analyzer/analyzer.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/macaron/slsa_analyzer/build_tool/base_build_tool.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 5 additions & 5 deletions src/macaron/slsa_analyzer/checks/build_as_code_check.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions src/macaron/slsa_analyzer/checks/build_service_check.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -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)

Expand Down
173 changes: 170 additions & 3 deletions src/macaron/slsa_analyzer/ci_service/jenkins.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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})"
Loading

0 comments on commit dbe2525

Please sign in to comment.