fluidattacks_core_git 12.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import uuid
6
+
7
+ import boto3
8
+ from botocore.exceptions import (
9
+ ClientError,
10
+ )
11
+
12
+ from fluidattacks_core.git.utils import run_git
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ def extract_region(url: str) -> str:
18
+ pattern = r"codecommit::([a-z0-9-]+)://"
19
+ match = re.search(pattern, url)
20
+ if match:
21
+ return match.group(1)
22
+ return "us-east-1"
23
+
24
+
25
+ async def assume_role_and_execute_git_ls_remote(
26
+ arn: str,
27
+ repo_url: str,
28
+ branch: str,
29
+ org_external_id: str,
30
+ *,
31
+ follow_redirects: bool = False,
32
+ ) -> tuple[str | None, str | None]:
33
+ try:
34
+ sts_client = boto3.client("sts")
35
+ assumed_role = sts_client.assume_role(
36
+ RoleArn=arn,
37
+ RoleSessionName=f"session-{uuid.uuid4()}",
38
+ ExternalId=org_external_id,
39
+ )
40
+ credentials = assumed_role["Credentials"]
41
+
42
+ return await codecommit_ls_remote(
43
+ env={
44
+ "AWS_ACCESS_KEY_ID": credentials["AccessKeyId"],
45
+ "AWS_SECRET_ACCESS_KEY": credentials["SecretAccessKey"],
46
+ "AWS_SESSION_TOKEN": credentials["SessionToken"],
47
+ "AWS_DEFAULT_REGION": extract_region(repo_url),
48
+ },
49
+ branch=branch,
50
+ repo_url=repo_url,
51
+ follow_redirects=follow_redirects,
52
+ )
53
+
54
+ except ClientError as exc:
55
+ err_message = "Error executing ls-remote with codecommit"
56
+ LOGGER.exception(
57
+ err_message,
58
+ extra={
59
+ "extra": {
60
+ "repo_url": repo_url,
61
+ "arn": arn,
62
+ "org_external_id": org_external_id,
63
+ "exc": exc,
64
+ },
65
+ },
66
+ )
67
+
68
+ return None, err_message
69
+
70
+
71
+ async def _execute_git_command(
72
+ *,
73
+ branch: str,
74
+ env: dict[str, str],
75
+ url: str,
76
+ follow_redirects: bool = False,
77
+ ) -> tuple[bytes, bytes, int]:
78
+ return await run_git(
79
+ "-c",
80
+ "http.sslVerify=false",
81
+ "-c",
82
+ f"http.followRedirects={follow_redirects}",
83
+ "ls-remote",
84
+ "--",
85
+ url,
86
+ branch,
87
+ env={**os.environ.copy(), **env},
88
+ timeout=20,
89
+ )
90
+
91
+
92
+ async def codecommit_ls_remote(
93
+ *,
94
+ branch: str,
95
+ env: dict[str, str],
96
+ repo_url: str,
97
+ follow_redirects: bool = False,
98
+ ) -> tuple[str | None, str | None]:
99
+ try:
100
+ stdout, stderr, return_code = await _execute_git_command(
101
+ branch=branch,
102
+ env=env,
103
+ follow_redirects=follow_redirects,
104
+ url=repo_url,
105
+ )
106
+ except asyncio.exceptions.TimeoutError:
107
+ return None, "git ls-remote time out"
108
+
109
+ if return_code == 0:
110
+ return stdout.decode().split("\t")[0], None
111
+
112
+ return None, stderr.decode("utf-8")
113
+
114
+
115
+ async def call_codecommit_ls_remote(
116
+ repo_url: str,
117
+ arn: str,
118
+ branch: str,
119
+ org_external_id: str,
120
+ *,
121
+ follow_redirects: bool = False,
122
+ ) -> tuple[str | None, str | None]:
123
+ return await assume_role_and_execute_git_ls_remote(
124
+ repo_url=repo_url,
125
+ arn=arn,
126
+ branch=branch,
127
+ org_external_id=org_external_id,
128
+ follow_redirects=follow_redirects,
129
+ )
@@ -0,0 +1,7 @@
1
+ DEFAULT_DOWNLOAD_BUFFER_SIZE = 64 * 1024 # 64KB
2
+
3
+ MULTIPART_THRESHOLD = 5 * 1024 * 1024 * 1024
4
+ MULTIPART_PART_SIZE = 50 * 1024 * 1024
5
+ MAX_RETRIES_PER_PART = 3
6
+ MAX_S3_PARTS = 10_000
7
+ UPLOAD_CONCURRENCY = 8
@@ -0,0 +1,47 @@
1
+ import logging
2
+ import os
3
+ from contextlib import suppress
4
+ from pathlib import Path
5
+
6
+ from pathspec import PathSpec
7
+ from pathspec import util as pathspec_util
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ def remove_empty_directories(repo_path: str) -> None:
13
+ for root, dirs, _ in os.walk(repo_path, topdown=False):
14
+ for name in dirs:
15
+ path = os.path.join(root, name) # noqa: PTH118
16
+ try:
17
+ if not os.listdir(path): # noqa: PTH208
18
+ Path(path).rmdir()
19
+ except FileNotFoundError:
20
+ LOGGER.exception(
21
+ "Error removing empty directory", extra={"extra": {"dir_path": path}}
22
+ )
23
+ continue
24
+
25
+
26
+ def delete_out_of_scope_files(git_ignore: list[str], repo_path: str) -> None:
27
+ # Compute what files should be deleted according to the scope rules
28
+ spec = PathSpec.from_lines("gitwildmatch", git_ignore)
29
+ try:
30
+ matches = list(spec.match_tree(repo_path, follow_links=False))
31
+ except pathspec_util.RecursionError:
32
+ LOGGER.exception(
33
+ "RecursionError while matching tree (symlink cycle detected)",
34
+ extra={"extra": {"repo_path": repo_path}},
35
+ )
36
+ return
37
+
38
+ for match in matches:
39
+ if match.startswith(".git/"):
40
+ continue
41
+
42
+ file_path = os.path.join(repo_path, match) # noqa: PTH118
43
+ if Path(file_path).is_file():
44
+ with suppress(FileNotFoundError):
45
+ Path(file_path).unlink()
46
+
47
+ remove_empty_directories(repo_path)
@@ -0,0 +1,47 @@
1
+ import logging
2
+
3
+ import aiofiles
4
+ import aiohttp
5
+ import anyio
6
+
7
+ from .constants import DEFAULT_DOWNLOAD_BUFFER_SIZE
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ async def _write_response_to_file(
13
+ response: aiohttp.ClientResponse,
14
+ destination_path: str,
15
+ download_buffer_size: int,
16
+ ) -> bool:
17
+ async with aiofiles.open(destination_path, "wb") as file:
18
+ while True:
19
+ try:
20
+ chunk = await response.content.read(download_buffer_size)
21
+ except TimeoutError:
22
+ LOGGER.exception("Read timeout for path %s", destination_path)
23
+ return False
24
+ if not chunk:
25
+ break
26
+ await file.write(chunk)
27
+ return await anyio.Path(destination_path).exists()
28
+
29
+
30
+ async def download_file(
31
+ *,
32
+ url: str,
33
+ destination_path: str,
34
+ download_buffer_size: int = DEFAULT_DOWNLOAD_BUFFER_SIZE,
35
+ ) -> bool:
36
+ timeout = aiohttp.ClientTimeout(total=60 * 60, connect=30)
37
+ async with aiohttp.ClientSession(timeout=timeout) as session: # noqa: SIM117
38
+ async with session.get(url) as response:
39
+ if response.status != 200:
40
+ LOGGER.error(
41
+ "Failed to download file: HTTP %s, for path %s",
42
+ response.status,
43
+ destination_path,
44
+ )
45
+ return False
46
+
47
+ return await _write_response_to_file(response, destination_path, download_buffer_size)
@@ -0,0 +1,148 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ import tarfile
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import anyio
9
+ from git import GitError
10
+ from git.cmd import Git
11
+ from git.repo import Repo
12
+
13
+ from .constants import DEFAULT_DOWNLOAD_BUFFER_SIZE
14
+ from .delete_files import delete_out_of_scope_files
15
+ from .download_file import download_file
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ def _is_member_safe(
21
+ member: tarfile.TarInfo,
22
+ ) -> bool:
23
+ return not (
24
+ member.issym() or member.islnk() or Path(member.name).is_absolute() or "../" in member.name
25
+ )
26
+
27
+
28
+ def _safe_extract_tar(tar_handler: tarfile.TarFile, file_path: Path) -> bool:
29
+ for member in tar_handler.getmembers():
30
+ if not _is_member_safe(member):
31
+ LOGGER.error("Unsafe path detected: %s", member.name)
32
+ continue
33
+ try:
34
+ tar_handler.extract(member, path=file_path, numeric_owner=True)
35
+ except tarfile.ExtractError:
36
+ LOGGER.exception("Error extracting %s", member.name)
37
+
38
+ return True
39
+
40
+
41
+ def remove_symlinks_in_directory(directory: str) -> None:
42
+ for root, dirs, files in os.walk(directory):
43
+ for name in (*dirs, *files):
44
+ file_path = os.path.join(root, name) # noqa: PTH118
45
+ if Path(file_path).is_symlink():
46
+ Path(file_path).unlink(missing_ok=True)
47
+
48
+
49
+ def _reset_repo_sync(repo_path: str) -> bool:
50
+ try:
51
+ Path.cwd()
52
+ except OSError:
53
+ LOGGER.exception("Failed to get the working directory: %s", repo_path)
54
+ os.chdir(repo_path)
55
+
56
+ try:
57
+ Git().execute(
58
+ [
59
+ "git",
60
+ "config",
61
+ "--global",
62
+ "--add",
63
+ "safe.directory",
64
+ "*",
65
+ ],
66
+ )
67
+ except GitError:
68
+ LOGGER.exception("Failed to add safe directory %s", repo_path)
69
+
70
+ try:
71
+ repo = Repo(repo_path)
72
+ repo.git.reset("--hard", "HEAD")
73
+ except GitError:
74
+ LOGGER.exception("Expand repositories has failed for repository %s", repo_path)
75
+
76
+ return False
77
+
78
+ if repo.working_dir:
79
+ remove_symlinks_in_directory(str(repo.working_dir))
80
+
81
+ return True
82
+
83
+
84
+ async def reset_repo(repo_path: str) -> bool:
85
+ return await anyio.to_thread.run_sync(_reset_repo_sync, repo_path)
86
+
87
+
88
+ def _extract_and_replace_sync(file_path: Path, tmp_path: Path, destination_path: Path) -> bool:
89
+ with tarfile.open(file_path, "r:gz") as tar_handler:
90
+ _safe_extract_tar(tar_handler, tmp_path)
91
+
92
+ extracted_dirs = [d for d in tmp_path.iterdir() if d.is_dir()]
93
+ if not extracted_dirs:
94
+ LOGGER.error("No directory found in the extracted archive: %s", destination_path)
95
+ return False
96
+
97
+ if len(extracted_dirs) > 1:
98
+ LOGGER.warning(
99
+ "Multiple directories found in archive, using first one: %s",
100
+ destination_path,
101
+ )
102
+ extracted_dir = extracted_dirs[0]
103
+
104
+ if destination_path.exists():
105
+ shutil.rmtree(destination_path)
106
+
107
+ shutil.move(extracted_dir, destination_path)
108
+ return True
109
+
110
+
111
+ async def download_repo_from_s3(
112
+ download_url: str,
113
+ destination_path: Path,
114
+ git_ignore: list[str] | None = None,
115
+ *,
116
+ download_buffer_size: int = DEFAULT_DOWNLOAD_BUFFER_SIZE,
117
+ ) -> bool:
118
+ await anyio.Path(destination_path.parent).mkdir(parents=True, exist_ok=True)
119
+ with tempfile.TemporaryDirectory(prefix="fluidattacks_", ignore_cleanup_errors=True) as tmpdir:
120
+ tmp_path = Path(tmpdir)
121
+ file_path = tmp_path / "repo.tar.gz"
122
+ result = await download_file(
123
+ url=download_url,
124
+ destination_path=str(file_path.absolute()),
125
+ download_buffer_size=download_buffer_size,
126
+ )
127
+ if not result:
128
+ LOGGER.error("Failed to download repository from %s", download_url)
129
+ return False
130
+
131
+ try:
132
+ if not await anyio.to_thread.run_sync(
133
+ _extract_and_replace_sync, file_path, tmp_path, destination_path
134
+ ):
135
+ return False
136
+ except OSError:
137
+ LOGGER.exception(
138
+ "Error downloading repository", extra={"extra": {"path": destination_path}}
139
+ )
140
+ return False
141
+
142
+ if not await reset_repo(str(destination_path)):
143
+ shutil.rmtree(destination_path, ignore_errors=True)
144
+ return False
145
+
146
+ delete_out_of_scope_files(git_ignore or [], str(destination_path))
147
+
148
+ return True
@@ -0,0 +1,230 @@
1
+ import asyncio
2
+ import base64
3
+ import logging
4
+ from contextlib import suppress
5
+
6
+ import aiohttp
7
+ from fluidattacks_core.http.client import request
8
+ from fluidattacks_core.http.validations import HTTPValidationError
9
+ from urllib3.exceptions import LocationParseError
10
+ from urllib3.util import Url, parse_url
11
+
12
+ from fluidattacks_core.git.classes import InvalidParameter
13
+ from fluidattacks_core.git.utils import format_url, get_https_git_config_args, run_git
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ def format_redirected_url(
19
+ original_url: Url,
20
+ redirect_url: Url,
21
+ ) -> str:
22
+ return (
23
+ redirect_url._replace(
24
+ query=None,
25
+ path=(redirect_url.path or "").removesuffix("info/refs"),
26
+ ).url
27
+ if original_url.host == redirect_url.host
28
+ else original_url.url
29
+ )
30
+
31
+
32
+ async def get_redirected_url(
33
+ *,
34
+ url: str,
35
+ user: str | None,
36
+ password: str | None,
37
+ token: str | None,
38
+ is_pat: bool,
39
+ ) -> str:
40
+ try:
41
+ uri = parse_url(url)._replace(auth=None).url
42
+ except LocationParseError:
43
+ uri = url
44
+ if user is not None and password is not None:
45
+ return await _get_redirected_url(
46
+ uri,
47
+ authorization="Basic " + base64.b64encode(f"{user}:{password}".encode()).decode(),
48
+ )
49
+ if token is not None and is_pat:
50
+ return await _get_redirected_url(
51
+ uri,
52
+ authorization=("Basic " + base64.b64encode(f":{token}".encode()).decode()),
53
+ )
54
+ if token is not None and not is_pat:
55
+ return await _get_redirected_url(uri, authorization=f"Bearer {token}")
56
+ if url.startswith("http"):
57
+ return await _get_redirected_url(url)
58
+ raise InvalidParameter
59
+
60
+
61
+ async def _get_redirected_url(
62
+ url: str,
63
+ authorization: str | None = None,
64
+ ) -> str:
65
+ try:
66
+ return await _get_url(url, authorization=authorization)
67
+ except (aiohttp.ClientError, TimeoutError) as exc:
68
+ LOGGER.warning(
69
+ "Failed to get redirected-url",
70
+ extra={"extra": {"url": url, "exc": exc}},
71
+ )
72
+ raise
73
+ except (ValueError, HTTPValidationError) as exc:
74
+ LOGGER.warning(
75
+ "Failed validation to get redirected-url",
76
+ extra={"extra": {"url": url, "exc": exc}},
77
+ )
78
+ raise
79
+
80
+
81
+ async def _get_url(
82
+ original_url: str,
83
+ *,
84
+ redirect_url: str = "",
85
+ max_retries: int = 5,
86
+ authorization: str | None = None,
87
+ ) -> str:
88
+ try:
89
+ original = parse_url(original_url.removesuffix("/"))
90
+ url = parse_url(redirect_url.removesuffix("/")) if redirect_url else original
91
+ except LocationParseError as exc:
92
+ msg = f"Invalid URL {redirect_url}"
93
+ raise HTTPValidationError(msg) from exc
94
+
95
+ if max_retries < 1:
96
+ return format_redirected_url(original, url)
97
+
98
+ # https://git-scm.com/book/en/v2/Git-Internals-Transfer-Protocols
99
+ url = (
100
+ url._replace(path=(url.path or "") + "/info/refs")
101
+ if not (url.path or "").endswith("info/refs")
102
+ else url
103
+ )
104
+ url = url._replace(query="service=git-upload-pack")
105
+
106
+ result = await request(
107
+ url.url,
108
+ method="GET",
109
+ headers={
110
+ "Host": url.host or "",
111
+ "Accept": "*/*",
112
+ "Accept-Encoding": "deflate, gzip",
113
+ "Pragma": "no-cache",
114
+ "Accept-Language": "*",
115
+ "User-Agent": "FluidAttacksAPIClient/1.0",
116
+ **({"Authorization": authorization} if authorization else {}),
117
+ },
118
+ timeout=20,
119
+ )
120
+ if result.status == 200:
121
+ return format_redirected_url(original, url)
122
+ if result.status > 300 and result.status < 400 and "Location" in result.headers:
123
+ with suppress(LocationParseError):
124
+ _url = parse_url(result.headers["Location"])
125
+ return await _get_url(
126
+ original_url,
127
+ redirect_url=result.headers["Location"],
128
+ max_retries=max_retries - 1,
129
+ authorization=authorization if _url.host == url.host else None,
130
+ )
131
+
132
+ return await _get_url(
133
+ original_url,
134
+ redirect_url=result.headers["Location"],
135
+ max_retries=max_retries - 1,
136
+ )
137
+
138
+ return format_redirected_url(original, url)
139
+
140
+
141
+ async def _execute_git_command(
142
+ *,
143
+ url: str,
144
+ branch: str,
145
+ is_pat: bool,
146
+ token: str | None = None,
147
+ follow_redirects: bool = False,
148
+ ) -> tuple[bytes, bytes, int]:
149
+ git_config_args = get_https_git_config_args(
150
+ follow_redirects=follow_redirects,
151
+ is_pat=is_pat,
152
+ token=token,
153
+ )
154
+ return await run_git(*git_config_args, "ls-remote", "--", url, branch, timeout=20)
155
+
156
+
157
+ async def https_ls_remote( # noqa: PLR0913
158
+ *,
159
+ repo_url: str,
160
+ branch: str,
161
+ user: str | None = None,
162
+ password: str | None = None,
163
+ token: str | None = None,
164
+ provider: str | None = None,
165
+ is_pat: bool = False,
166
+ follow_redirects: bool = False,
167
+ ) -> tuple[str | None, str | None]:
168
+ url = format_url(
169
+ repo_url=repo_url,
170
+ user=user,
171
+ password=password,
172
+ token=token,
173
+ provider=provider,
174
+ is_pat=is_pat,
175
+ )
176
+ try:
177
+ stdout, stderr, return_code = await _execute_git_command(
178
+ url=url,
179
+ branch=branch,
180
+ is_pat=is_pat,
181
+ token=token,
182
+ follow_redirects=follow_redirects,
183
+ )
184
+ except asyncio.exceptions.TimeoutError:
185
+ return None, "git ls-remote time out"
186
+
187
+ if return_code == 0:
188
+ return stdout.decode().split("\t")[0], None
189
+
190
+ return None, stderr.decode("utf-8")
191
+
192
+
193
+ async def call_https_ls_remote( # noqa: PLR0913
194
+ *,
195
+ repo_url: str,
196
+ user: str | None,
197
+ password: str | None,
198
+ token: str | None,
199
+ branch: str,
200
+ provider: str | None,
201
+ is_pat: bool,
202
+ follow_redirects: bool = False,
203
+ ) -> tuple[str | None, str | None]:
204
+ if user is not None and password is not None:
205
+ return await https_ls_remote(
206
+ repo_url=repo_url,
207
+ user=user,
208
+ password=password,
209
+ branch=branch,
210
+ follow_redirects=follow_redirects,
211
+ )
212
+
213
+ if token is not None:
214
+ return await https_ls_remote(
215
+ repo_url=repo_url,
216
+ token=token,
217
+ branch=branch,
218
+ provider=provider or "",
219
+ is_pat=is_pat,
220
+ follow_redirects=follow_redirects,
221
+ )
222
+
223
+ if repo_url.startswith("http"):
224
+ return await https_ls_remote(
225
+ repo_url=repo_url,
226
+ branch=branch,
227
+ follow_redirects=follow_redirects,
228
+ )
229
+
230
+ raise InvalidParameter
File without changes
@@ -0,0 +1,45 @@
1
+ from .codecommit_utils import call_codecommit_ls_remote
2
+ from .https_utils import call_https_ls_remote
3
+ from .ssh_utils import call_ssh_ls_remote
4
+
5
+
6
+ async def ls_remote( # noqa: PLR0913
7
+ repo_url: str,
8
+ repo_branch: str,
9
+ *,
10
+ credential_key: str | None = None,
11
+ user: str | None = None,
12
+ password: str | None = None,
13
+ token: str | None = None,
14
+ provider: str | None = None,
15
+ is_pat: bool = False,
16
+ arn: str | None = None,
17
+ org_external_id: str | None = None,
18
+ follow_redirects: bool = False,
19
+ ) -> tuple[str | None, str | None]:
20
+ if credential_key is not None:
21
+ return await call_ssh_ls_remote(
22
+ repo_url,
23
+ credential_key,
24
+ repo_branch,
25
+ )
26
+
27
+ if arn is not None and org_external_id is not None:
28
+ return await call_codecommit_ls_remote(
29
+ repo_url,
30
+ arn,
31
+ repo_branch,
32
+ org_external_id=org_external_id,
33
+ follow_redirects=follow_redirects,
34
+ )
35
+
36
+ return await call_https_ls_remote(
37
+ repo_url=repo_url,
38
+ user=user,
39
+ password=password,
40
+ token=token,
41
+ branch=repo_branch,
42
+ provider=provider,
43
+ is_pat=is_pat,
44
+ follow_redirects=follow_redirects,
45
+ )