diff --git a/src/dvsim/utils/git.py b/src/dvsim/utils/git.py index ce537bdf..0776d9de 100644 --- a/src/dvsim/utils/git.py +++ b/src/dvsim/utils/git.py @@ -5,6 +5,7 @@ """Git utility functions.""" from pathlib import Path +from urllib.parse import urlsplit, urlunsplit from git import Repo @@ -13,6 +14,26 @@ __all__ = ("repo_root",) +def strip_url_credentials(url: str) -> str: + """Remove any username/password from a URL, keeping the rest of it intact. + + A CI checkout leaves its access token in the remote URL as ``https://oauth2:@host/org/repo``. + That URL is recorded in the run metadata and published in the reports, which get archived, so the + token would outlive the run that leaked it. + + An ssh URL keeps its user: ``git@`` names an ssh account rather than an identity to authenticate + as, so dropping it gives a URL that no longer addresses the remote. A password is stripped + whatever the scheme carries it. + """ + parts = urlsplit(url) + userinfo, _, host = parts.netloc.rpartition("@") + if not userinfo: + return url + if ":" not in userinfo and parts.scheme not in ("http", "https"): + return url + return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment)) + + def repo_root(path: Path) -> Path | None: """Given a sub dir in a git repo provide the root path. @@ -56,7 +77,11 @@ def git_is_dirty(path: Path | None = None) -> bool: def git_origin_url(path: Path | None = None) -> str | None: - """Get the git remote origin url, or None if no ``origin`` remote is configured.""" + """Get the git remote origin url, or None if no ``origin`` remote is configured. + + Any credentials the remote carries are stripped, so that the url is safe to record in run + metadata and reports. + """ root = repo_root(path=path or Path.cwd()) if root is None: @@ -68,7 +93,7 @@ def git_origin_url(path: Path | None = None) -> str | None: if "origin" not in [remote.name for remote in r.remotes]: return None - return r.remote("origin").url + return strip_url_credentials(r.remote("origin").url) def git_https_url_with_commit(path: Path | None = None) -> str | None: diff --git a/tests/utils/test_git.py b/tests/utils/test_git.py index 06bde5ce..5516fe22 100644 --- a/tests/utils/test_git.py +++ b/tests/utils/test_git.py @@ -132,6 +132,66 @@ def test_git_origin_url(tmp_path: Path) -> None: equal_to(url), ) + @staticmethod + @pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "https://github.com/lowRISC/test.git", + "https://github.com/lowRISC/test.git", + ), + ("git@github.com:lowRISC/test.git", "git@github.com:lowRISC/test.git"), + # An ssh user is part of the address rather than a credential, so it stays. Dropping + # it would leave a url that no longer reaches the remote. + ( + "ssh://git@github.com/lowRISC/test.git", + "ssh://git@github.com/lowRISC/test.git", + ), + ( + "git+ssh://git@github.com/lowRISC/test.git", + "git+ssh://git@github.com/lowRISC/test.git", + ), + ( + "https://oauth2:ghs_secrettoken@github.com/lowRISC/test.git", + "https://github.com/lowRISC/test.git", + ), + ( + "https://someuser@github.com/lowRISC/test.git", + "https://github.com/lowRISC/test.git", + ), + # A password is a credential whatever the scheme carries it. + ( + "ssh://user:secretpw@github.com/lowRISC/test.git", + "ssh://github.com/lowRISC/test.git", + ), + ], + ids=[ + "plain_https", + "ssh_scp_form", + "ssh_url_form", + "git_ssh_scheme", + "token", + "user_only", + "ssh_with_password", + ], + ) + def test_git_origin_url_strips_credentials(tmp_path: Path, url: str, expected: str) -> None: + """A token in the remote url never reaches the caller, whatever the url's shape. + + The url is recorded in the run metadata and published in the reports, so a credential + left in it outlives the run. Both ssh forms carry an '@' without being credentialed, and + have to survive untouched or the recorded url stops addressing the remote. + """ + r = Repo.init(path=tmp_path) + + file = tmp_path / "a" + file.write_text("file to commit") + r.index.add([file]) + r.index.commit("initial commit") + r.create_remote("origin", url) + + assert_that(git_origin_url(tmp_path), equal_to(expected)) + @staticmethod @pytest.mark.parametrize( ("url", "expected"), @@ -141,6 +201,10 @@ def test_git_origin_url(tmp_path: Path) -> None: "https://github.com/lowRISC/test.git", "https://github.com/lowRISC/test/tree/{commit}", ), + ( + "https://oauth2:ghs_secrettoken@github.com/lowRISC/test.git", + "https://github.com/lowRISC/test/tree/{commit}", + ), ], ) def test_git_https_url_with_commit(tmp_path: Path, url: str, expected: str) -> None: