Skip to content

Commit f28b5d5

Browse files
takuro-satoCopilot
andcommitted
Prevent ORAS execution from the current directory
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6b0d11a commit f28b5d5

4 files changed

Lines changed: 151 additions & 26 deletions

File tree

‎src/confcom/azext_confcom/command/fragment_attach.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ def oras_attach(
1919
platform: Optional[str] = None,
2020
) -> None:
2121
cmd = [
22-
"oras",
2322
"attach",
2423
"--artifact-type", "application/x-ms-ccepolicy-frag",
2524
]
@@ -51,7 +50,7 @@ def oras_attach(
5150
])
5251

5352
subprocess.run(
54-
cmd,
53+
[oras_proxy.resolve_oras_path(), *cmd],
5554
check=True,
5655
timeout=120,
5756
)

‎src/confcom/azext_confcom/command/fragment_push.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import tempfile
1010
from typing import Optional
1111

12+
from azext_confcom import oras_proxy
1213
from azext_confcom.errors import eprint
1314

1415

@@ -18,7 +19,7 @@ def oras_push(
1819
) -> None:
1920
subprocess.run(
2021
[
21-
"oras",
22+
oras_proxy.resolve_oras_path(),
2223
"push",
2324
"--artifact-type", "application/x-ms-ccepolicy-frag",
2425
manifest_tag,

‎src/confcom/azext_confcom/oras_proxy.py‎

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import os
88
import re
9+
import shutil
910
import subprocess
1011
from tempfile import mkdtemp
1112
from typing import List, Optional
@@ -24,6 +25,43 @@
2425
logger = get_logger(__name__)
2526

2627

28+
def resolve_oras_path() -> str:
29+
"""Resolve ORAS outside CWD to prevent executable search path hijacking."""
30+
current_directory = os.path.normcase(os.path.realpath(os.getcwd()))
31+
executable_extensions = [""]
32+
if os.name == "nt":
33+
executable_extensions = [
34+
extension if extension.startswith(".") else f".{extension}"
35+
for extension in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(os.pathsep)
36+
if extension
37+
]
38+
39+
for path_entry in os.environ.get("PATH", "").split(os.pathsep):
40+
# Normalize quoted entries before rejecting empty or relative paths.
41+
path_entry = path_entry.strip().strip('"')
42+
if not path_entry or not os.path.isabs(path_entry):
43+
continue
44+
if os.name == "nt" and not os.path.splitdrive(path_entry)[0]:
45+
continue
46+
47+
resolved_directory = os.path.realpath(path_entry)
48+
# Exclude CWD as recommended for preventing binary planting attacks:
49+
# https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-security
50+
if os.path.normcase(resolved_directory) == current_directory:
51+
continue
52+
53+
for extension in executable_extensions:
54+
oras_path = shutil.which(os.path.join(resolved_directory, f"oras{extension}"))
55+
if oras_path:
56+
resolved_oras_path = os.path.realpath(os.path.abspath(oras_path))
57+
if os.path.normcase(os.path.dirname(resolved_oras_path)) != current_directory:
58+
return resolved_oras_path
59+
60+
eprint("ORAS CLI not found in absolute PATH entries. Please install ORAS CLI: "
61+
"https://oras.land/docs/installation")
62+
raise RuntimeError("ORAS path resolution failed")
63+
64+
2765
def prepend_docker_registry(image_name: str) -> str:
2866
"""
2967
Normalize a Docker image reference by adding `docker.io/library` if necessary.
@@ -54,7 +92,12 @@ def prepend_docker_registry(image_name: str) -> str:
5492

5593

5694
def call_oras_cli(args, check=False):
57-
return subprocess.run(args, check=check, capture_output=True, timeout=120)
95+
return subprocess.run(
96+
[resolve_oras_path(), *args],
97+
check=check,
98+
capture_output=True,
99+
timeout=120,
100+
)
58101

59102

60103
def manifest_fetch(image_tag: str) -> Optional[dict]:
@@ -64,7 +107,7 @@ def manifest_fetch(image_tag: str) -> Optional[dict]:
64107
"""
65108
try:
66109
result = subprocess.run(
67-
["oras", "manifest", "fetch", "--format", "json", image_tag],
110+
[resolve_oras_path(), "manifest", "fetch", "--format", "json", image_tag],
68111
capture_output=True,
69112
text=True,
70113
timeout=30,
@@ -86,7 +129,7 @@ def manifest_fetch_config(image_tag: str) -> Optional[dict]:
86129
"""
87130
try:
88131
result = subprocess.run(
89-
["oras", "manifest", "fetch-config", image_tag],
132+
[resolve_oras_path(), "manifest", "fetch-config", image_tag],
90133
capture_output=True,
91134
text=True,
92135
timeout=30,
@@ -179,7 +222,7 @@ def discover(
179222
# normalize the name in case the docker registry is implied
180223
image = prepend_docker_registry(image)
181224

182-
arg_list = ["oras", "discover", image, "-o", "json", "--artifact-type", ARTIFACT_TYPE]
225+
arg_list = ["discover", image, "-o", "json", "--artifact-type", ARTIFACT_TYPE]
183226
if platform:
184227
arg_list.extend(["--platform", platform])
185228

@@ -242,7 +285,7 @@ def pull(
242285
logger.info("Pulling fragment: %s", full_path)
243286

244287
temp_folder = mkdtemp()
245-
arg_list = ["oras", "pull", full_path, "-o", temp_folder]
288+
arg_list = ["pull", full_path, "-o", temp_folder]
246289

247290
item = call_oras_cli(arg_list, check=False)
248291

@@ -340,13 +383,10 @@ def pull_all_standalone_fragments(fragment_imports):
340383

341384

342385
def check_oras_cli():
343-
text = "ORAS CLI not installed. Please install ORAS CLI: https://oras.land/docs/installation"
344-
try:
345-
item = call_oras_cli(["oras", "version"], check=False)
346-
if item.returncode != 0:
347-
eprint(text)
348-
except FileNotFoundError:
349-
eprint(text)
386+
item = call_oras_cli(["version"], check=False)
387+
if item.returncode != 0:
388+
eprint("ORAS CLI could not be executed. Please install ORAS CLI: "
389+
"https://oras.land/docs/installation")
350390

351391

352392
# used for image-attached fragments
@@ -355,7 +395,6 @@ def attach_fragment_to_image(image_name: str, filename: str, platform: Optional[
355395
image_name += ":latest"
356396
# attach the fragment to the image
357397
arg_list = [
358-
"oras",
359398
"attach",
360399
"--artifact-type",
361400
ARTIFACT_TYPE,
@@ -406,7 +445,6 @@ def generate_imports_from_image_name(image_name: str, minimum_svn: str) -> List[
406445
def push_fragment_to_registry(feed_name: str, filename: str) -> None:
407446
# push the fragment to the registry
408447
arg_list = [
409-
"oras",
410448
"push",
411449
feed_name,
412450
"--artifact-type",

‎src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py‎

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,83 @@ def test_acifragmentgen_fragment_push(docker_image, cert_chain, capsysbinary):
205205
assert f.read() == signed_fragment
206206

207207

208+
# Unit tests for ORAS path resolution
209+
210+
211+
def test_resolve_oras_path_uses_absolute_path_entries(monkeypatch, tmp_path):
212+
"""A planted CWD executable is ignored in favor of another absolute PATH entry."""
213+
from azext_confcom.oras_proxy import resolve_oras_path
214+
215+
current_directory = tmp_path / "work"
216+
trusted_directory = tmp_path / "tools"
217+
current_directory.mkdir()
218+
trusted_directory.mkdir()
219+
oras_name = "oras.exe" if os.name == "nt" else "oras"
220+
planted_oras = current_directory / oras_name
221+
trusted_oras = trusted_directory / oras_name
222+
planted_oras.touch()
223+
trusted_oras.touch()
224+
planted_oras.chmod(0o755)
225+
trusted_oras.chmod(0o755)
226+
227+
monkeypatch.chdir(current_directory)
228+
if os.name == "nt":
229+
monkeypatch.setenv("PATHEXT", ".EXE")
230+
monkeypatch.setenv(
231+
"PATH",
232+
os.pathsep.join(["", ".", str(current_directory), "relative-tools", str(trusted_directory)]),
233+
)
234+
235+
assert resolve_oras_path() == os.path.realpath(trusted_oras)
236+
237+
238+
def test_resolve_oras_path_errors_when_no_safe_candidate(monkeypatch, tmp_path):
239+
"""Resolution fails instead of executing ORAS from the current directory."""
240+
from azext_confcom.oras_proxy import resolve_oras_path
241+
242+
monkeypatch.chdir(tmp_path)
243+
monkeypatch.setenv("PATH", os.pathsep.join(["", ".", str(tmp_path), "relative-tools"]))
244+
245+
with patch("azext_confcom.oras_proxy.shutil.which") as mock_which:
246+
with pytest.raises(SystemExit):
247+
resolve_oras_path()
248+
249+
mock_which.assert_not_called()
250+
251+
252+
def test_call_oras_cli_executes_resolved_absolute_path():
253+
"""The shared ORAS runner never passes a bare executable name to subprocess."""
254+
from azext_confcom.oras_proxy import call_oras_cli
255+
256+
with patch(
257+
"azext_confcom.oras_proxy.resolve_oras_path",
258+
return_value=os.path.abspath(os.path.join("trusted-tools", "oras.exe")),
259+
) as mock_resolve, patch("azext_confcom.oras_proxy.subprocess.run") as mock_run:
260+
call_oras_cli(["discover", "example.azurecr.io/image:tag"])
261+
262+
mock_resolve.assert_called_once_with()
263+
command = mock_run.call_args.args[0]
264+
assert os.path.isabs(command[0])
265+
assert command[1:] == ["discover", "example.azurecr.io/image:tag"]
266+
267+
268+
@patch("azext_confcom.command.fragment_push.subprocess.run")
269+
def test_oras_push_executes_resolved_absolute_path(mock_run):
270+
"""The fragment push command executes the resolved ORAS path."""
271+
from azext_confcom.command.fragment_push import oras_push
272+
273+
resolved_path = os.path.abspath(os.path.join("trusted-tools", "oras.exe"))
274+
with patch(
275+
"azext_confcom.command.fragment_push.oras_proxy.resolve_oras_path",
276+
return_value=resolved_path,
277+
):
278+
oras_push("fragment.rego.cose", "example.azurecr.io/fragments:test")
279+
280+
command = mock_run.call_args.args[0]
281+
assert command[0] == resolved_path
282+
assert command[1] == "push"
283+
284+
208285
# ── Unit tests for prepend_docker_registry ─────────────────────────────────
209286

210287

@@ -361,14 +438,19 @@ def test_oras_attach_explicit_platform(mock_run):
361438

362439
mock_run.return_value = MagicMock(returncode=0)
363440

364-
oras_attach(
365-
signed_fragment_path="/tmp/fragment.cose",
366-
manifest_tag="myregistry.io/myimage:latest",
367-
platform="linux/amd64",
368-
)
441+
with patch(
442+
"azext_confcom.command.fragment_attach.oras_proxy.resolve_oras_path",
443+
return_value=os.path.abspath(os.path.join("trusted-tools", "oras.exe")),
444+
):
445+
oras_attach(
446+
signed_fragment_path="/tmp/fragment.cose",
447+
manifest_tag="myregistry.io/myimage:latest",
448+
platform="linux/amd64",
449+
)
369450

370451
mock_run.assert_called_once()
371452
cmd = mock_run.call_args[0][0]
453+
assert os.path.isabs(cmd[0])
372454
assert "--platform" in cmd
373455
assert "linux/amd64" in cmd
374456
assert "application/cose-x509+rego" in " ".join(cmd)
@@ -385,13 +467,18 @@ def test_oras_attach_auto_detected_platform(mock_platforms, mock_run):
385467

386468
mock_run.return_value = MagicMock(returncode=0)
387469

388-
oras_attach(
389-
signed_fragment_path="/tmp/fragment.cose",
390-
manifest_tag="myregistry.io/myimage:latest",
391-
)
470+
with patch(
471+
"azext_confcom.command.fragment_attach.oras_proxy.resolve_oras_path",
472+
return_value=os.path.abspath(os.path.join("trusted-tools", "oras.exe")),
473+
):
474+
oras_attach(
475+
signed_fragment_path="/tmp/fragment.cose",
476+
manifest_tag="myregistry.io/myimage:latest",
477+
)
392478

393479
mock_run.assert_called_once()
394480
cmd = mock_run.call_args[0][0]
481+
assert os.path.isabs(cmd[0])
395482
assert "--platform" in cmd
396483
assert "linux/amd64" in cmd
397484
assert "application/cose-x509+rego" in " ".join(cmd)

0 commit comments

Comments
 (0)