Skip to content

Commit 23c7fdb

Browse files
authored
feat: add callgraph and build cmd detection for Jenkins (#977)
This PR introduces the construction of callgraphs for bash scripts inlined within Jenkinsfile configurations. Signed-off-by: behnazh-w <[email protected]>
1 parent 075cb7d commit 23c7fdb

File tree

10 files changed

+245
-26
lines changed

10 files changed

+245
-26
lines changed

src/macaron/config/defaults.ini

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,6 @@ jenkins =
158158
withMaven
159159
buildPlugin
160160
asfMavenTlpStdBuild
161-
./mvnw
162-
./mvn
163161

164162
[builder.maven.ci.deploy]
165163
github_actions =
@@ -232,39 +230,41 @@ wrapper_files =
232230
[builder.gradle.ci.build]
233231
github_actions = actions/setup-java
234232
travis_ci =
235-
jdk
236-
./gradlew
233+
gradle
237234
circle_ci =
238-
./gradlew
235+
gradle
239236
gitlab_ci =
240-
./gradlew
237+
gradle
241238
jenkins =
242-
./gradlew
243239

244240
[builder.gradle.ci.deploy]
245241
github_actions =
246242
# This action can be used to deploy artifacts to a JFrog artifactory server.
247243
spring-io/artifactory-deploy-action
248244
travis_ci =
249245
artifactoryPublish
246+
gradle publish
250247
./gradlew publish
251248
publishToSonatype
252249
gradle-git-publish
253250
gitPublishPush
254251
circle_ci =
255252
artifactoryPublish
253+
gradle publish
256254
./gradlew publish
257255
publishToSonatype
258256
gradle-git-publish
259257
gitPublishPush
260258
gitlab_ci =
261259
artifactoryPublish
260+
gradle publish
262261
./gradlew publish
263262
publishToSonatype
264263
gradle-git-publish
265264
gitPublishPush
266265
jenkins =
267266
artifactoryPublish
267+
gradle publish
268268
./gradlew publish
269269
publishToSonatype
270270
gradle-git-publish

src/macaron/slsa_analyzer/analyzer.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -994,10 +994,7 @@ def _determine_ci_services(self, analyze_ctx: AnalyzeContext, git_service: BaseG
994994

995995
# Parse configuration files and generate IRs.
996996
# Add the bash commands to the context object to be used by other checks.
997-
callgraph = ci_service.build_call_graph(
998-
analyze_ctx.component.repository.fs_path,
999-
os.path.relpath(analyze_ctx.component.repository.fs_path, analyze_ctx.output_dir),
1000-
)
997+
callgraph = ci_service.build_call_graph(analyze_ctx.component.repository.fs_path)
1001998
analyze_ctx.dynamic_data["ci_services"].append(
1002999
CIInfo(
10031000
service=ci_service,

src/macaron/slsa_analyzer/build_tool/base_build_tool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""This module contains the BaseBuildTool class to be inherited by other specific Build Tools."""
@@ -44,7 +44,7 @@ class BuildToolCommand(TypedDict):
4444
ci_path: str
4545

4646
#: The CI step object that calls the command.
47-
step_node: BaseNode
47+
step_node: BaseNode | None
4848

4949
#: The list of name of reachable variables that contain secrets."""
5050
reachable_secrets: list[str]

src/macaron/slsa_analyzer/checks/build_as_code_check.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
GitHubWorkflowType,
2828
)
2929
from macaron.slsa_analyzer.ci_service.gitlab_ci import GitLabCI
30-
from macaron.slsa_analyzer.ci_service.jenkins import Jenkins
3130
from macaron.slsa_analyzer.ci_service.travis import Travis
3231
from macaron.slsa_analyzer.registry import registry
3332
from macaron.slsa_analyzer.slsa_req import ReqName
@@ -264,10 +263,11 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData:
264263
trigger_link=trigger_link,
265264
job_id=(
266265
build_command["step_node"].caller.name
267-
if isinstance(build_command["step_node"].caller, GitHubJobNode)
266+
if build_command["step_node"]
267+
and isinstance(build_command["step_node"].caller, GitHubJobNode)
268268
else None
269269
),
270-
step_id=build_command["step_node"].node_id,
270+
step_id=build_command["step_node"].node_id if build_command["step_node"] else None,
271271
step_name=(
272272
build_command["step_node"].name
273273
if isinstance(build_command["step_node"], BashNode)
@@ -301,7 +301,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData:
301301

302302
# We currently don't parse these CI configuration files.
303303
# We just look for a keyword for now.
304-
for unparsed_ci in (Jenkins, Travis, CircleCI, GitLabCI):
304+
for unparsed_ci in (Travis, CircleCI, GitLabCI):
305305
if isinstance(ci_service, unparsed_ci):
306306
if tool.ci_deploy_kws[ci_service.name]:
307307
deploy_kw, config_name = ci_service.has_kws_in_config(

src/macaron/slsa_analyzer/checks/build_service_check.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""This module contains the BuildServiceCheck class."""
@@ -18,7 +18,6 @@
1818
from macaron.slsa_analyzer.ci_service.base_ci_service import BaseCIService, NoneCIService
1919
from macaron.slsa_analyzer.ci_service.circleci import CircleCI
2020
from macaron.slsa_analyzer.ci_service.gitlab_ci import GitLabCI
21-
from macaron.slsa_analyzer.ci_service.jenkins import Jenkins
2221
from macaron.slsa_analyzer.ci_service.travis import Travis
2322
from macaron.slsa_analyzer.registry import registry
2423
from macaron.slsa_analyzer.slsa_req import ReqName
@@ -170,7 +169,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData:
170169

171170
# We currently don't parse these CI configuration files.
172171
# We just look for a keyword for now.
173-
for unparsed_ci in (Jenkins, Travis, CircleCI, GitLabCI):
172+
for unparsed_ci in (Travis, CircleCI, GitLabCI):
174173
if isinstance(ci_service, unparsed_ci):
175174
if tool.ci_build_kws[ci_service.name]:
176175
build_kw, config_name = ci_service.has_kws_in_config(

src/macaron/slsa_analyzer/checks/infer_artifact_pipeline_check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData:
196196
# Obtain the job and step calling the deploy command.
197197
# This data must have been found already by the build-as-code check.
198198
build_predicate = ci_info["build_info_results"].statement["predicate"]
199-
if build_predicate is None:
199+
if build_predicate is None or build_predicate["buildType"] != f"Custom {ci_service.name}":
200200
continue
201201
build_entry_point = json_extract(build_predicate, ["invocation", "configSource", "entryPoint"], str)
202202

src/macaron/slsa_analyzer/ci_service/jenkins.py

Lines changed: 170 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
1-
# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""This module analyzes Jenkins CI."""
55

6+
import glob
7+
import logging
8+
import os
9+
import re
10+
from collections.abc import Iterable
11+
from enum import Enum
12+
from typing import Any
13+
614
from macaron.code_analyzer.call_graph import BaseNode, CallGraph
715
from macaron.config.defaults import defaults
16+
from macaron.config.global_config import global_config
17+
from macaron.errors import ParseError
18+
from macaron.parsers import bashparser
19+
from macaron.repo_verifier.repo_verifier import BaseBuildTool
20+
from macaron.slsa_analyzer.build_tool.base_build_tool import BuildToolCommand
821
from macaron.slsa_analyzer.ci_service.base_ci_service import BaseCIService
922

23+
logger: logging.Logger = logging.getLogger(__name__)
24+
1025

1126
class Jenkins(BaseCIService):
1227
"""This class implements Jenkins CI service."""
@@ -29,7 +44,17 @@ def get_workflows(self, repo_path: str) -> list:
2944
list
3045
The list of workflow files in this repository.
3146
"""
32-
return []
47+
if not self.is_detected(repo_path=repo_path):
48+
logger.debug("There are no Jenkinsfile configurations.")
49+
return []
50+
51+
workflow_files = []
52+
for conf in self.entry_conf:
53+
workflows = glob.glob(os.path.join(repo_path, conf))
54+
if workflows:
55+
logger.debug("Found Jenkinsfile configuration.")
56+
workflow_files.extend(workflows)
57+
return workflow_files
3358

3459
def load_defaults(self) -> None:
3560
"""Load the default values from defaults.ini."""
@@ -56,7 +81,111 @@ def build_call_graph(self, repo_path: str, macaron_path: str = "") -> CallGraph:
5681
CallGraph : CallGraph
5782
The call graph built for the CI.
5883
"""
59-
return CallGraph(BaseNode(), "")
84+
if not macaron_path:
85+
macaron_path = global_config.macaron_path
86+
87+
root: BaseNode = BaseNode()
88+
call_graph = CallGraph(root, repo_path)
89+
90+
# To match lines that start with sh '' or sh ''' ''' (either single or triple quotes)
91+
# TODO: we need to support multi-line cases.
92+
pattern = r"^\s*sh\s+'{1,3}(.*?)'{1,3}$"
93+
workflow_files = self.get_workflows(repo_path)
94+
95+
for workflow_path in workflow_files:
96+
try:
97+
with open(workflow_path, encoding="utf-8") as wf:
98+
lines = wf.readlines()
99+
except OSError as error:
100+
logger.debug("Unable to read Jenkinsfile %s: %s", workflow_path, error)
101+
return call_graph
102+
103+
# Add internal workflow.
104+
workflow_name = os.path.basename(workflow_path)
105+
workflow_node = JenkinsNode(
106+
name=workflow_name,
107+
node_type=JenkinsNodeType.INTERNAL,
108+
source_path=workflow_path,
109+
caller=root,
110+
)
111+
root.add_callee(workflow_node)
112+
113+
# Find matching lines.
114+
for line in lines:
115+
match = re.match(pattern, line)
116+
if not match:
117+
continue
118+
119+
try:
120+
parsed_bash_script = bashparser.parse(match.group(1), macaron_path=macaron_path)
121+
except ParseError as error:
122+
logger.debug(error)
123+
continue
124+
125+
# TODO: Similar to GitHub Actions, we should enable support for recursive calls to bash scripts
126+
# within Jenkinsfiles. While the implementation should be relatively straightforward, it’s
127+
# recommended to first refactor the bashparser to make it agnostic to GitHub Actions.
128+
bash_node = bashparser.BashNode(
129+
"jenkins_inline_cmd",
130+
bashparser.BashScriptType.INLINE,
131+
workflow_path,
132+
parsed_step_obj=None,
133+
parsed_bash_obj=parsed_bash_script,
134+
node_id=None,
135+
caller=workflow_node,
136+
)
137+
workflow_node.add_callee(bash_node)
138+
139+
return call_graph
140+
141+
def get_build_tool_commands(self, callgraph: CallGraph, build_tool: BaseBuildTool) -> Iterable[BuildToolCommand]:
142+
"""
143+
Traverse the callgraph and find all the reachable build tool commands.
144+
145+
Parameters
146+
----------
147+
callgraph: CallGraph
148+
The callgraph reachable from the CI workflows.
149+
build_tool: BaseBuildTool
150+
The corresponding build tool for which shell commands need to be detected.
151+
152+
Yields
153+
------
154+
BuildToolCommand
155+
The object that contains the build command as well useful contextual information.
156+
157+
Raises
158+
------
159+
CallGraphError
160+
Error raised when an error occurs while traversing the callgraph.
161+
"""
162+
yield from sorted(
163+
self._get_build_tool_commands(callgraph=callgraph, build_tool=build_tool),
164+
key=str,
165+
)
166+
167+
def _get_build_tool_commands(self, callgraph: CallGraph, build_tool: BaseBuildTool) -> Iterable[BuildToolCommand]:
168+
"""Traverse the callgraph and find all the reachable build tool commands."""
169+
for node in callgraph.bfs():
170+
# We are just interested in nodes that have bash commands.
171+
if isinstance(node, bashparser.BashNode):
172+
# The Jenkins configuration that triggers the path in the callgraph.
173+
workflow_node = node.caller
174+
175+
# Find the bash commands that call the build tool.
176+
for cmd in node.parsed_bash_obj.get("commands", []):
177+
if build_tool.is_build_command(cmd):
178+
yield BuildToolCommand(
179+
ci_path=workflow_node.source_path if workflow_node else "",
180+
command=cmd,
181+
step_node=None,
182+
language=build_tool.language,
183+
language_versions=None,
184+
language_distributions=None,
185+
language_url=None,
186+
reachable_secrets=[],
187+
events=None,
188+
)
60189

61190
def has_latest_run_passed(
62191
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(
85214
The feed back of the check, or empty if no passing workflow is found.
86215
"""
87216
return ""
217+
218+
219+
class JenkinsNodeType(str, Enum):
220+
"""This class represents Jenkins node type."""
221+
222+
INTERNAL = "internal" # Configurations declared in one file.
223+
224+
225+
class JenkinsNode(BaseNode):
226+
"""This class represents a callgraph node for Jenkinsfile configuration."""
227+
228+
def __init__(
229+
self,
230+
name: str,
231+
node_type: JenkinsNodeType,
232+
source_path: str,
233+
**kwargs: Any,
234+
) -> None:
235+
"""Initialize instance.
236+
237+
Parameters
238+
----------
239+
name : str
240+
Name of the workflow.
241+
node_type : JenkinsNodeType
242+
The type of node.
243+
source_path : str
244+
The path of the workflow.
245+
caller: BaseNode | None
246+
The caller node.
247+
"""
248+
super().__init__(**kwargs)
249+
self.name = name
250+
self.node_type: JenkinsNodeType = node_type
251+
self.source_path = source_path
252+
253+
def __str__(self) -> str:
254+
return f"JenkinsNodeType({self.name},{self.node_type})"

tests/integration/cases/jenkinsci_plotplugin/policy.dl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
#include "prelude.dl"
55

66
Policy("test_policy", component_id, "") :-
7-
check_passed(component_id, "mcn_build_script_1"),
8-
check_passed(component_id, "mcn_build_service_1"),
97
check_passed(component_id, "mcn_version_control_system_1"),
8+
check_passed(component_id, "mcn_build_script_1"),
9+
check_failed(component_id, "mcn_build_service_1"),
1010
check_failed(component_id, "mcn_build_as_code_1"),
1111
check_failed(component_id, "mcn_find_artifact_pipeline_1"),
1212
check_failed(component_id, "mcn_provenance_available_1"),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* Copyright (c) 2024 - 2025, Oracle and/or its affiliates. All rights reserved. */
2+
/* Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. */
3+
4+
#include "prelude.dl"
5+
6+
Policy("test_policy", component_id, "") :-
7+
check_passed(component_id, "mcn_version_control_system_1"),
8+
check_passed(component_id, "mcn_scm_authenticity_1"),
9+
check_passed(component_id, "mcn_build_tool_1"),
10+
check_passed(component_id, "mcn_build_script_1"),
11+
check_passed(component_id, "mcn_build_service_1"),
12+
check_passed(component_id, "mcn_build_as_code_1"),
13+
build_as_code_check(
14+
bs_check_id,
15+
"maven",
16+
"jenkins",
17+
_,
18+
"java",
19+
_,
20+
_,
21+
_,
22+
"[\"./mvnw\", \"clean\", \"package\", \"deploy\", \"-pl\", \"dubbo-dependencies-bom\"]"
23+
),
24+
check_facts(bs_check_id, _, component_id,_,_),
25+
check_failed(component_id, "mcn_find_artifact_pipeline_1"),
26+
check_failed(component_id, "mcn_provenance_available_1"),
27+
check_failed(component_id, "mcn_provenance_derived_commit_1"),
28+
check_failed(component_id, "mcn_provenance_derived_repo_1"),
29+
check_failed(component_id, "mcn_provenance_expectation_1"),
30+
check_failed(component_id, "mcn_provenance_witness_level_one_1"),
31+
check_failed(component_id, "mcn_trusted_builder_level_three_1"),
32+
is_repo_url(component_id, "https://github.com/apache/dubbo").
33+
34+
apply_policy_to("test_policy", component_id) :-
35+
is_component(component_id, "pkg:maven/org.apache.dubbo/[email protected]").

0 commit comments

Comments
 (0)