Skip to content

Commit 9ad67a9

Browse files
committed
feat: add support for python packaging and publishing tools in build_as_code and build_service checks
Signed-off-by: sophie-bates <[email protected]>
1 parent f7bec7f commit 9ad67a9

File tree

6 files changed

+158
-11
lines changed

6 files changed

+158
-11
lines changed

src/macaron/config/defaults.ini

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,44 @@ entry_conf =
218218
setup.cfg
219219
pyproject.toml
220220
build_configs =
221+
packager =
222+
pip
223+
pip3
224+
flit
225+
conda
226+
publisher =
227+
twine
228+
flit
229+
conda
230+
builder_module =
231+
python
232+
python3
233+
build_arg =
234+
install
235+
build
236+
setup.py
237+
deploy_arg =
238+
publish
239+
upload
240+
[builder.pip.ci.deploy]
241+
github_actions = pypa/gh-action-pypi-publish
221242

222243
# This is the spec for trusted Poetry packaging tools.
223244
[builder.poetry]
224245
entry_conf = pyproject.toml
225246
build_configs = poetry.lock
247+
builder =
248+
poetry
249+
poetry-core
250+
builder_module =
251+
python
252+
python3
253+
build_arg =
254+
build
255+
deploy_arg =
256+
publish
257+
[builder.poetry.ci.deploy]
258+
github_actions = pypa/gh-action-pypi-publish
226259

227260
# This is the spec for GitHub Actions CI.
228261
[ci.github_actions]

src/macaron/slsa_analyzer/build_tool/base_build_tool.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ def __init__(self, name: str) -> None:
5656
self.entry_conf: list[str] = []
5757
self.build_configs: list[str] = []
5858
self.builder: list[str] = []
59+
self.packager: list[str] = []
60+
self.publisher: list[str] = []
61+
self.builder_module: list[str] = []
5962
self.build_arg: list[str] = []
6063
self.deploy_arg: list[str] = []
6164
self.ci_build_kws: dict[str, list[str]] = {

src/macaron/slsa_analyzer/build_tool/pip.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ def load_defaults(self) -> None:
2929
if hasattr(self, item):
3030
setattr(self, item, defaults.get_list("builder.pip", item))
3131

32+
if "builder.pip.ci.deploy" in defaults:
33+
for item in defaults["builder.pip.ci.deploy"]:
34+
if item in self.ci_deploy_kws:
35+
self.ci_deploy_kws[item] = defaults.get_list("builder.pip.ci.deploy", item)
36+
3237
def is_detected(self, repo_path: str) -> bool:
3338
"""Return True if this build tool is used in the target repo.
3439

src/macaron/slsa_analyzer/build_tool/poetry.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ def load_defaults(self) -> None:
3333
if hasattr(self, item):
3434
setattr(self, item, defaults.get_list("builder.poetry", item))
3535

36+
if "builder.pip.ci.deploy" in defaults:
37+
for item in defaults["builder.pip.ci.deploy"]:
38+
if item in self.ci_deploy_kws:
39+
self.ci_deploy_kws[item] = defaults.get_list("builder.pip.ci.deploy", item)
40+
3641
def is_detected(self, repo_path: str) -> bool:
3742
"""Return True if this build tool is used in the target repo.
3843
@@ -56,6 +61,8 @@ def is_detected(self, repo_path: str) -> bool:
5661
files_detected = glob.glob(pattern, recursive=True)
5762

5863
if files_detected:
64+
# TODO: this implementation assumes one build type, so when multiple build types are supported, this
65+
# needs to be updated.
5966
# Take the highest level file, if there are two at the same level, take the first in the list.
6067
file_path = min(files_detected, key=lambda x: len(Path(x).parts))
6168
try:

src/macaron/slsa_analyzer/checks/build_as_code_check.py

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
# Copyright (c) 2022 - 2022, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2023, 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 BuildAsCodeCheck class."""
55

66
import logging
77
import os
88

9+
from macaron.config.defaults import defaults
910
from macaron.slsa_analyzer.analyze_context import AnalyzeContext
1011
from macaron.slsa_analyzer.build_tool.base_build_tool import BaseBuildTool, NoneBuildTool
1112
from macaron.slsa_analyzer.checks.base_check import BaseCheck
1213
from macaron.slsa_analyzer.checks.check_result import CheckResult, CheckResultType
1314
from macaron.slsa_analyzer.ci_service.base_ci_service import NoneCIService
1415
from macaron.slsa_analyzer.ci_service.circleci import CircleCI
16+
from macaron.slsa_analyzer.ci_service.github_actions import GHWorkflowType
1517
from macaron.slsa_analyzer.ci_service.gitlab_ci import GitLabCI
1618
from macaron.slsa_analyzer.ci_service.jenkins import Jenkins
1719
from macaron.slsa_analyzer.ci_service.travis import Travis
@@ -50,23 +52,43 @@ def __init__(self) -> None:
5052
def _has_deploy_command(self, commands: list[list[str]], build_tool: BaseBuildTool) -> str:
5153
"""Check if the bash command is a build and deploy command."""
5254
for com in commands:
55+
5356
# Check for empty or invalid commands.
5457
if not com or not com[0]:
5558
continue
5659
# The first argument in a bash command is the program name.
5760
# So first check that the program name is a supported build tool name.
5861
# We need to handle cases where the the first argument is a path to the program.
62+
# TODO: support for not being a supported build tool first
5963
cmd_program_name = os.path.basename(com[0])
6064
if not cmd_program_name:
6165
logger.debug("Found invalid program name %s.", com[0])
6266
continue
63-
if any(build_cmd for build_cmd in build_tool.builder if build_cmd == cmd_program_name):
64-
# Check the arguments in the bash command for the deploy goals.
65-
# If there are no deploy args for this build tool, accept as deploy command.
67+
68+
# Account for Python projects having separate tools for packaging and publishing.
69+
if build_tool.publisher:
70+
deploy_tool = build_tool.publisher
71+
else:
72+
deploy_tool = build_tool.builder
73+
74+
check_build_commands = any(build_cmd for build_cmd in deploy_tool if build_cmd == cmd_program_name)
75+
76+
# Support the use of python modules, i.e. 'python -m pip install'
77+
check_module_build_commands = any(
78+
module == cmd_program_name and com[1] and com[1] == "-m" and com[2] in deploy_tool
79+
for module in build_tool.builder_module
80+
)
81+
prog_name_index = 2 if check_module_build_commands else 0
82+
83+
# logger.info("com: %s", com[(prog_name_index + 1) :])
84+
85+
if check_build_commands or check_module_build_commands:
6686
if not build_tool.deploy_arg:
6787
logger.info("No deploy arguments required. Accept %s as deploy command.", str(com))
6888
return str(com)
69-
for word in com[1:]:
89+
logger.info(build_tool.deploy_arg)
90+
91+
for word in com[(prog_name_index + 1) :]:
7092
# TODO: allow plugin versions in arguments, e.g., maven-plugin:1.6.8:deploy.
7193
if word in build_tool.deploy_arg:
7294
logger.info("Found deploy command %s.", str(com))
@@ -90,7 +112,7 @@ def run_check(self, ctx: AnalyzeContext, check_result: CheckResult) -> CheckResu
90112
"""
91113
# Get the build tool identified by the mcn_version_control_system_1, which we depend on.
92114
build_tool = ctx.dynamic_data["build_spec"].get("tool")
93-
ci_services = ctx.dynamic_data["ci_services"]
115+
ci_services = ctx.dynamic_data["ci_services"] # keep storing things here
94116

95117
# Checking if a build tool is discovered for this repo.
96118
if build_tool and not isinstance(build_tool, NoneBuildTool):
@@ -99,6 +121,67 @@ def run_check(self, ctx: AnalyzeContext, check_result: CheckResult) -> CheckResu
99121
# Checking if a CI service is discovered for this repo.
100122
if isinstance(ci_service, NoneCIService):
101123
continue
124+
125+
trusted_deploy_actions = defaults.get_list("builder.pip.ci.deploy", "github_actions", fallback=[])
126+
127+
# Check for use of a trusted Github Action to publish/deploy.
128+
# TODO: verify that deplyment is legitimate and not a test
129+
if trusted_deploy_actions:
130+
for callee in ci_info["callgraph"].bfs():
131+
workflow_name = callee.name.split("@")[0]
132+
133+
if not workflow_name or callee.node_type not in [
134+
GHWorkflowType.EXTERNAL,
135+
GHWorkflowType.REUSABLE,
136+
]:
137+
logger.debug("Workflow %s is not relevant. Skipping...", callee.name)
138+
continue
139+
if workflow_name in trusted_deploy_actions:
140+
trigger_link = ci_service.api_client.get_file_link(
141+
ctx.repo_full_name,
142+
ctx.commit_sha,
143+
ci_service.api_client.get_relative_path_of_workflow(
144+
os.path.basename(callee.caller_path)
145+
),
146+
)
147+
# TODO: verify that caller_path and CI_PATH are the same
148+
deploy_action_source_link = ci_service.api_client.get_file_link(
149+
ctx.repo_full_name, ctx.commit_sha, callee.caller_path
150+
)
151+
152+
html_url = ci_service.has_latest_run_passed(
153+
ctx.repo_full_name,
154+
ctx.branch_name,
155+
ctx.commit_sha,
156+
ctx.commit_date,
157+
os.path.basename(callee.caller_path),
158+
)
159+
160+
# TODO: include in the justification multiple cases of external action usage
161+
justification: list[str | dict[str, str]] = [
162+
{
163+
f"The target repository uses build tool {build_tool.name}"
164+
" to deploy": deploy_action_source_link,
165+
"The build is triggered by": trigger_link,
166+
},
167+
f"Deploy action: {workflow_name}",
168+
{"The status of the build can be seen at": html_url}
169+
if html_url
170+
else "However, could not find a passing workflow run.",
171+
]
172+
check_result["justification"].extend(justification)
173+
if ctx.dynamic_data["is_inferred_prov"] and ci_info["provenances"]:
174+
predicate = ci_info["provenances"][0]["predicate"]
175+
predicate["buildType"] = f"Custom {ci_service.name}"
176+
predicate["builder"]["id"] = deploy_action_source_link
177+
predicate["invocation"]["configSource"][
178+
"uri"
179+
] = f"{ctx.remote_path}@refs/heads/{ctx.branch_name}"
180+
predicate["invocation"]["configSource"]["digest"]["sha1"] = ctx.commit_sha
181+
predicate["invocation"]["configSource"]["entryPoint"] = trigger_link
182+
predicate["metadata"]["buildInvocationId"] = html_url
183+
return CheckResultType.PASSED
184+
102185
for bash_cmd in ci_info["bash_commands"]:
103186
deploy_cmd = self._has_deploy_command(bash_cmd["commands"], build_tool)
104187
if deploy_cmd:
@@ -121,7 +204,7 @@ def run_check(self, ctx: AnalyzeContext, check_result: CheckResult) -> CheckResu
121204
os.path.basename(bash_cmd["CI_path"]),
122205
)
123206

124-
justification: list[str | dict[str, str]] = [
207+
justification_cmd: list[str | dict[str, str]] = [
125208
{
126209
f"The target repository uses build tool {build_tool.name} to deploy": bash_source_link,
127210
"The build is triggered by": trigger_link,
@@ -131,7 +214,7 @@ def run_check(self, ctx: AnalyzeContext, check_result: CheckResult) -> CheckResu
131214
if html_url
132215
else "However, could not find a passing workflow run.",
133216
]
134-
check_result["justification"].extend(justification)
217+
check_result["justification"].extend(justification_cmd)
135218
if ctx.dynamic_data["is_inferred_prov"] and ci_info["provenances"]:
136219
predicate = ci_info["provenances"][0]["predicate"]
137220
predicate["buildType"] = f"Custom {ci_service.name}"

src/macaron/slsa_analyzer/checks/build_service_check.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2022 - 2022, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2023, 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."""
@@ -51,13 +51,29 @@ def _has_build_command(self, commands: list[list[str]], build_tool: BaseBuildToo
5151
if not cmd_program_name:
5252
logger.debug("Found invalid program name %s.", com[0])
5353
continue
54-
if any(build_cmd for build_cmd in build_tool.builder if build_cmd == cmd_program_name):
54+
55+
if build_tool.packager:
56+
builder = build_tool.packager
57+
else:
58+
builder = build_tool.builder
59+
60+
check_build_commands = any(build_cmd for build_cmd in builder if build_cmd == cmd_program_name)
61+
62+
# Support for use of Python modules.
63+
check_module_build_commands = any(
64+
module == cmd_program_name and com[1] and com[1] == "-m" and com[2] in builder
65+
for module in build_tool.builder_module
66+
)
67+
68+
prog_name_index = 2 if check_module_build_commands else 0
69+
70+
if check_build_commands or check_module_build_commands:
5571
# Check the arguments in the bash command for the build goals.
5672
# If there are no build args for this build tool, accept as build command.
5773
if not build_tool.build_arg:
5874
logger.info("No build arguments required. Accept %s as build command.", str(com))
5975
return str(com)
60-
for word in com[1:]:
76+
for word in com[(prog_name_index + 1) :]:
6177
# TODO: allow plugin versions in arguments, e.g., maven-plugin:1.6.8:package.
6278
if word in build_tool.build_arg:
6379
logger.info("Found build command %s.", str(com))

0 commit comments

Comments
 (0)