Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/macaron/config/defaults.ini
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,6 @@ jenkins =
withMaven
buildPlugin
asfMavenTlpStdBuild
./mvnw
./mvn

[builder.maven.ci.deploy]
github_actions =
Expand Down Expand Up @@ -232,39 +230,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
5 changes: 1 addition & 4 deletions src/macaron/slsa_analyzer/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -994,10 +994,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
8 changes: 4 additions & 4 deletions src/macaron/slsa_analyzer/checks/build_as_code_check.py
Original file line number Diff line number Diff line change
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 @@ -264,10 +263,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 @@ -301,7 +301,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
Expand Up @@ -196,7 +196,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)
# TODO: we need to support 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})"
4 changes: 2 additions & 2 deletions tests/integration/cases/jenkinsci_plotplugin/policy.dl
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
#include "prelude.dl"

Policy("test_policy", component_id, "") :-
check_passed(component_id, "mcn_build_script_1"),
check_passed(component_id, "mcn_build_service_1"),
check_passed(component_id, "mcn_version_control_system_1"),
check_passed(component_id, "mcn_build_script_1"),
check_failed(component_id, "mcn_build_service_1"),
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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* 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"),
check_passed(component_id, "mcn_build_as_code_1"),
build_as_code_check(
bs_check_id,
"maven",
"jenkins",
_,
"java",
_,
_,
_,
"[\"./mvnw\", \"clean\", \"package\", \"deploy\", \"-pl\", \"dubbo-dependencies-bom\"]"
),
check_facts(bs_check_id, _, component_id,_,_),
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_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/[email protected]").
Loading
Loading