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+
614from macaron .code_analyzer .call_graph import BaseNode , CallGraph
715from 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
821from macaron .slsa_analyzer .ci_service .base_ci_service import BaseCIService
922
23+ logger : logging .Logger = logging .getLogger (__name__ )
24+
1025
1126class 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+ # we need to account for both the single-line and 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 } )"
0 commit comments