Skip to content

Commit 8c4329c

Browse files
committed
Redact git tokens and surface stderr
1 parent c9766ba commit 8c4329c

File tree

4 files changed

+110
-9
lines changed

4 files changed

+110
-9
lines changed

src/prefect/runner/storage.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from prefect.filesystems import ReadableDeploymentStorage, WritableDeploymentStorage
2626
from prefect.logging.loggers import get_logger
2727
from prefect.utilities.collections import visit_collection
28+
from prefect.utilities.urls import redact_url_credentials
2829

2930

3031
@runtime_checkable
@@ -312,9 +313,17 @@ async def pull_code(self) -> None:
312313
await run_process(cmd, cwd=self.destination)
313314
self._logger.debug("Successfully fetched latest changes")
314315
except subprocess.CalledProcessError as exc:
315-
self._logger.error(
316-
f"Failed to fetch latest changes with exit code {exc}"
316+
stderr = (
317+
redact_url_credentials(exc.stderr.decode().strip())
318+
if exc.stderr
319+
else ""
317320
)
321+
message = (
322+
f"Failed to fetch latest changes with exit code {exc.returncode}"
323+
)
324+
if stderr:
325+
message += f": {stderr}"
326+
self._logger.error(message)
318327
shutil.rmtree(self.destination)
319328
await self._clone_repo()
320329

@@ -338,9 +347,17 @@ async def pull_code(self) -> None:
338347
await run_process(cmd, cwd=self.destination)
339348
self._logger.debug("Successfully pulled latest changes")
340349
except subprocess.CalledProcessError as exc:
341-
self._logger.error(
342-
f"Failed to pull latest changes with exit code {exc}"
350+
stderr = (
351+
redact_url_credentials(exc.stderr.decode().strip())
352+
if exc.stderr
353+
else ""
354+
)
355+
message = (
356+
f"Failed to pull latest changes with exit code {exc.returncode}"
343357
)
358+
if stderr:
359+
message += f": {stderr}"
360+
self._logger.error(message)
344361
shutil.rmtree(self.destination)
345362
await self._clone_repo()
346363

@@ -385,10 +402,17 @@ async def _clone_repo(self):
385402
except subprocess.CalledProcessError as exc:
386403
# Hide the command used to avoid leaking the access token
387404
exc_chain = None if self._credentials else exc
388-
raise RuntimeError(
389-
f"Failed to clone repository {self._url!r} with exit code"
390-
f" {exc.returncode}."
391-
) from exc_chain
405+
stderr = (
406+
redact_url_credentials(exc.stderr.decode().strip())
407+
if exc.stderr
408+
else ""
409+
)
410+
message = (
411+
f"Failed to clone repository {self._url!r} with exit code {exc.returncode}"
412+
)
413+
if stderr:
414+
message += f": {stderr}"
415+
raise RuntimeError(message) from exc_chain
392416

393417
if self._commit_sha:
394418
# Fetch the commit

src/prefect/utilities/urls.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import ipaddress
33
import socket
44
import urllib.parse
5+
import re
56
from logging import Logger
67
from string import Formatter
78
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
@@ -22,6 +23,31 @@
2223

2324
logger: Logger = get_logger("utilities.urls")
2425

26+
# Regular expression patterns used for credential redaction
27+
_URL_WITH_CREDENTIALS = re.compile(r"(https?://)[^:@\s]+:[^@/\s]+@")
28+
_URL_WITH_TOKEN = re.compile(r"(https?://)[^:@/\s]+@")
29+
30+
31+
def redact_url_credentials(text: str) -> str:
32+
"""Redact embedded credentials from URLs in a string.
33+
34+
This replaces any occurrences of ``https://user:token@host`` with
35+
``https://***:***@host`` and ``https://token@host`` with ``https://***@host``.
36+
37+
Args:
38+
text: A string that may contain URLs with credentials.
39+
40+
Returns:
41+
The string with any credentials redacted.
42+
"""
43+
44+
if not text:
45+
return text
46+
47+
text = _URL_WITH_CREDENTIALS.sub(r"\1***:***@", text)
48+
text = _URL_WITH_TOKEN.sub(r"\1***@", text)
49+
return text
50+
2551
# The following objects are excluded from UI URL generation because we lack a
2652
# directly-addressable URL:
2753
# artifact

tests/runner/test_storage.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
import shutil
3+
import subprocess
34
from pathlib import Path
45
from textwrap import dedent
56
from typing import Optional
@@ -792,6 +793,33 @@ async def test_clone_repo_with_commit_sha(
792793
mock_run_process.assert_has_awaits(expected_calls)
793794
assert mock_run_process.await_args_list == expected_calls
794795

796+
async def test_pull_code_logs_redacted_error(
797+
self, monkeypatch, tmp_path, caplog
798+
):
799+
repo = GitRepository(url="https://github.com/org/repo.git")
800+
repo.set_base_path(tmp_path)
801+
(repo.destination / ".git").mkdir(parents=True)
802+
803+
success = MagicMock()
804+
success.stdout = repo._url.encode()
805+
error = subprocess.CalledProcessError(
806+
returncode=1,
807+
cmd=["git", "pull"],
808+
stderr=b"fatal: https://oauth2:[email protected]/org/repo.git",
809+
)
810+
811+
run_process_mock = AsyncMock(side_effect=[success, error])
812+
monkeypatch.setattr("prefect.runner.storage.run_process", run_process_mock)
813+
clone_mock = AsyncMock()
814+
monkeypatch.setattr(GitRepository, "_clone_repo", clone_mock)
815+
816+
with caplog.at_level("ERROR"):
817+
await repo.pull_code()
818+
819+
clone_mock.assert_awaited_once()
820+
assert any("https://***:***@github.com" in r.message for r in caplog.records)
821+
assert any("fatal:" in r.message for r in caplog.records)
822+
795823

796824
class TestRemoteStorage:
797825
def test_init(self):

tests/utilities/test_urls.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
from prefect.settings import PREFECT_API_URL, PREFECT_UI_URL, temporary_settings
1515
from prefect.states import StateType
1616
from prefect.types._datetime import now
17-
from prefect.utilities.urls import url_for, validate_restricted_url
17+
from prefect.utilities.urls import (
18+
redact_url_credentials,
19+
url_for,
20+
validate_restricted_url,
21+
)
1822
from prefect.variables import Variable
1923

2024
MOCK_PREFECT_UI_URL = "https://ui.prefect.io"
@@ -415,3 +419,22 @@ def test_url_for_with_additional_format_kwargs_raises_if_placeholder_not_replace
415419
match="Unable to generate URL for worker because the following keys are missing: work_pool_name",
416420
):
417421
url_for(obj="worker", obj_id="123e4567-e89b-12d3-a456-42661417400")
422+
423+
424+
def test_redact_url_credentials_with_user_and_token():
425+
text = (
426+
"fatal: could not read https://oauth2:[email protected]/org/repo.git"
427+
)
428+
redacted = redact_url_credentials(text)
429+
assert "secret" not in redacted
430+
assert "oauth2" not in redacted
431+
assert (
432+
"https://***:***@github.com/org/repo.git" in redacted
433+
)
434+
435+
436+
def test_redact_url_credentials_with_token_only():
437+
text = "error: unable to access 'https://[email protected]/org/repo.git'"
438+
redacted = redact_url_credentials(text)
439+
assert "secret" not in redacted
440+
assert "https://***@github.com/org/repo.git" in redacted

0 commit comments

Comments
 (0)