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,408 @@
1
+ import asyncio
2
+ import io
3
+ import logging
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tarfile
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ import aiofiles
12
+
13
+ from fluidattacks_core.git.ssh_utils import (
14
+ parse_ssh_url,
15
+ ssh_git_env_context,
16
+ )
17
+ from fluidattacks_core.git.utils import format_url, get_https_git_config_args, run_git
18
+
19
+ LOGGER = logging.getLogger(__name__)
20
+
21
+ COMMIT_SHA_PATTERN = re.compile(r"^[0-9a-fA-F]{7,40}\Z")
22
+
23
+
24
+ def _is_commit_sha(ref: str) -> bool:
25
+ return bool(COMMIT_SHA_PATTERN.fullmatch(ref))
26
+
27
+
28
+ def _ref_for_fetch(ref: str) -> str:
29
+ if ref.startswith("origin/"):
30
+ return ref.removeprefix("origin/")
31
+ return ref
32
+
33
+
34
+ def _show_spec_for_ref(path: str) -> str:
35
+ return f"FETCH_HEAD:{path}"
36
+
37
+
38
+ async def _run_git(
39
+ env: dict[str, str],
40
+ config_args: list[str],
41
+ args: list[str],
42
+ *,
43
+ cwd: str | None = None,
44
+ ) -> tuple[bytes, bytes, int]:
45
+ try:
46
+ return await run_git(*config_args, *args, env=env, cwd=cwd, timeout=30.0)
47
+ except asyncio.exceptions.TimeoutError:
48
+ return b"", b"git command timed out", -1
49
+ except OSError as exc:
50
+ return b"", str(exc).encode(), -1
51
+
52
+
53
+ class _GitResult:
54
+ __slots__ = ("_error", "_stdout")
55
+
56
+ def __init__(
57
+ self,
58
+ error: str | None = None,
59
+ stdout: bytes | None = None,
60
+ ) -> None:
61
+ self._error = error
62
+ self._stdout = stdout
63
+
64
+ @property
65
+ def is_ok(self) -> bool:
66
+ return self._error is None
67
+
68
+ @property
69
+ def error(self) -> str | None:
70
+ return self._error
71
+
72
+ @property
73
+ def stdout(self) -> bytes | None:
74
+ return self._stdout
75
+
76
+ @classmethod
77
+ async def from_git(
78
+ cls,
79
+ env: dict[str, str],
80
+ config_args: list[str],
81
+ args: list[str],
82
+ *,
83
+ cwd: str | None = None,
84
+ ) -> "_GitResult":
85
+ stdout, stderr, code = await _run_git(env, config_args, args, cwd=cwd)
86
+ if code != 0:
87
+ return cls(stderr.decode("utf-8"))
88
+ return cls(stdout=stdout)
89
+
90
+ async def and_then(
91
+ self,
92
+ env: dict[str, str],
93
+ config_args: list[str],
94
+ args: list[str],
95
+ *,
96
+ cwd: str | None = None,
97
+ ) -> "_GitResult":
98
+ if not self.is_ok:
99
+ return self
100
+ return await _GitResult.from_git(env, config_args, args, cwd=cwd)
101
+
102
+
103
+ async def _run_git_sequence(
104
+ env: dict[str, str],
105
+ config_args: list[str],
106
+ steps: list[tuple[list[str], str | None]],
107
+ ) -> _GitResult:
108
+ result = await _GitResult.from_git(env, config_args, steps[0][0], cwd=steps[0][1])
109
+ for args, cwd in steps[1:]:
110
+ result = await result.and_then(env, config_args, args, cwd=cwd)
111
+ return result
112
+
113
+
114
+ async def _show_via_fetch_show( # noqa: PLR0913
115
+ env: dict[str, str],
116
+ config_args: list[str],
117
+ repo_url: str,
118
+ ref_fetch: str,
119
+ show_spec: str,
120
+ work_dir: str,
121
+ ) -> tuple[bytes | None, str | None]:
122
+ fetch_args = ["fetch"]
123
+ if not _is_commit_sha(ref_fetch):
124
+ fetch_args.extend(["--depth", "1"])
125
+ fetch_args.extend(["origin", ref_fetch])
126
+
127
+ steps: list[tuple[list[str], str | None]] = [
128
+ (["init"], work_dir),
129
+ (["remote", "add", "origin", repo_url], work_dir),
130
+ (fetch_args, work_dir),
131
+ (["show", show_spec], work_dir),
132
+ ]
133
+ result = await _run_git_sequence(env, config_args, steps)
134
+ if not result.is_ok:
135
+ return None, result.error
136
+ return result.stdout, None
137
+
138
+
139
+ async def _show_via_archive_remote(
140
+ env: dict[str, str],
141
+ config_args: list[str],
142
+ repo_url: str,
143
+ ref_fetch: str,
144
+ path: str,
145
+ ) -> tuple[bytes | None, str | None]:
146
+ stdout, stderr, code = await _run_git(
147
+ env,
148
+ config_args,
149
+ ["archive", f"--remote={repo_url}", ref_fetch, "--", path],
150
+ cwd=None,
151
+ )
152
+ if code != 0:
153
+ return None, stderr.decode("utf-8")
154
+ if len(stdout) == 0:
155
+ return None, "git archive produced empty output"
156
+ try:
157
+ with tarfile.open(fileobj=io.BytesIO(stdout), mode="r:") as tar:
158
+ for member in tar:
159
+ if (
160
+ member.isfile()
161
+ and (extracted := tar.extractfile(member))
162
+ and extracted is not None
163
+ ):
164
+ return extracted.read(), None
165
+ return None, "no file in archive"
166
+ except (tarfile.TarError, OSError) as exc:
167
+ return None, str(exc)
168
+
169
+
170
+ def _path_contained_in(base: Path, candidate: Path) -> bool:
171
+ try:
172
+ return candidate.resolve().is_relative_to(base.resolve())
173
+ except (ValueError, OSError):
174
+ return False
175
+
176
+
177
+ def _sparse_checkout_directory(path: str) -> str:
178
+ """Directory to pass to sparse-checkout set in cone mode.
179
+
180
+ Cone mode only accepts directories and includes all files under them.
181
+ For a file path like src/main.py we must set the parent directory (src)
182
+ so the file is actually checked out.
183
+ """
184
+ parent = Path(path).parent
185
+ return "." if not parent.parts else str(parent)
186
+
187
+
188
+ async def _show_via_sparse_checkout( # noqa: PLR0913
189
+ env: dict[str, str],
190
+ config_args: list[str],
191
+ repo_url: str,
192
+ ref_fetch: str,
193
+ path: str,
194
+ temp_dir: str,
195
+ ) -> tuple[bytes | None, str | None]:
196
+ sparse_dir = f"{temp_dir}/{uuid.uuid4()}"
197
+ sparse_dir_path = _sparse_checkout_directory(path)
198
+
199
+ fetch_args = ["fetch"]
200
+ if not _is_commit_sha(ref_fetch):
201
+ fetch_args.extend(["--depth", "1"])
202
+ fetch_args.extend(["origin", ref_fetch])
203
+
204
+ steps: list[tuple[list[str], str | None]] = [
205
+ (
206
+ [
207
+ "clone",
208
+ "--no-checkout",
209
+ "--depth",
210
+ "1",
211
+ repo_url,
212
+ Path(sparse_dir).name,
213
+ ],
214
+ temp_dir,
215
+ ),
216
+ (fetch_args, sparse_dir),
217
+ (["sparse-checkout", "init", "--cone"], sparse_dir),
218
+ (["sparse-checkout", "set", sparse_dir_path], sparse_dir),
219
+ (["checkout", "FETCH_HEAD"], sparse_dir),
220
+ ]
221
+ try:
222
+ result = await _run_git_sequence(env, config_args, steps)
223
+ if not result.is_ok:
224
+ return None, result.error
225
+
226
+ base = Path(sparse_dir)
227
+ file_path = base / path
228
+ if not _path_contained_in(base, file_path):
229
+ return None, "path escapes repository"
230
+ try:
231
+ async with aiofiles.open(file_path, "rb") as f:
232
+ return await f.read(), None
233
+ except OSError as exc:
234
+ return None, str(exc)
235
+ finally:
236
+ shutil.rmtree(sparse_dir, ignore_errors=True)
237
+
238
+
239
+ async def _try_show_strategies( # noqa: PLR0913
240
+ env: dict[str, str],
241
+ config_args: list[str],
242
+ repo_url: str,
243
+ ref_fetch: str,
244
+ show_spec: str,
245
+ path: str,
246
+ work_dir: str,
247
+ temp_dir: str,
248
+ ) -> tuple[bytes | None, str | None]:
249
+ content, err = await _show_via_fetch_show(
250
+ env,
251
+ config_args,
252
+ repo_url,
253
+ ref_fetch,
254
+ show_spec,
255
+ work_dir,
256
+ )
257
+ if content is not None:
258
+ return content, None
259
+ last_error = err or "fetch+show failed"
260
+ LOGGER.debug(
261
+ "show_file fallback: fetch+show failed, trying archive --remote",
262
+ extra={"extra": {"error": last_error}},
263
+ )
264
+
265
+ content, err = await _show_via_archive_remote(
266
+ env,
267
+ config_args,
268
+ repo_url,
269
+ ref_fetch,
270
+ path,
271
+ )
272
+ if content is not None:
273
+ return content, None
274
+ last_error = err or "archive --remote failed"
275
+ LOGGER.debug(
276
+ "show_file fallback: archive --remote failed, trying sparse-checkout",
277
+ extra={"extra": {"error": last_error}},
278
+ )
279
+
280
+ content, err = await _show_via_sparse_checkout(
281
+ env,
282
+ config_args,
283
+ repo_url,
284
+ ref_fetch,
285
+ path,
286
+ temp_dir,
287
+ )
288
+ if content is not None:
289
+ return content, None
290
+ return None, err or "sparse-checkout failed"
291
+
292
+
293
+ async def show_file_at_ref_ssh(
294
+ repo_url: str,
295
+ ref: str,
296
+ path: str,
297
+ temp_dir: str,
298
+ *,
299
+ credential_key: str,
300
+ ) -> tuple[bytes | None, str | None]:
301
+ ref_fetch = _ref_for_fetch(ref)
302
+ show_spec = _show_spec_for_ref(path)
303
+ work_dir = f"{temp_dir}/{uuid.uuid4()}"
304
+ Path(work_dir).mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
305
+ repo_url_parsed = parse_ssh_url(repo_url)
306
+ config_args: list[str] = []
307
+
308
+ try:
309
+ with ssh_git_env_context(temp_dir, credential_key) as env:
310
+ return await _try_show_strategies(
311
+ env,
312
+ config_args,
313
+ repo_url_parsed,
314
+ ref_fetch,
315
+ show_spec,
316
+ path,
317
+ work_dir,
318
+ temp_dir,
319
+ )
320
+ finally:
321
+ shutil.rmtree(work_dir, ignore_errors=True)
322
+
323
+
324
+ async def show_file_at_ref_https( # noqa: PLR0913
325
+ repo_url: str,
326
+ ref: str,
327
+ path: str,
328
+ temp_dir: str,
329
+ *,
330
+ user: str | None = None,
331
+ password: str | None = None,
332
+ token: str | None = None,
333
+ provider: str | None = None,
334
+ is_pat: bool = False,
335
+ follow_redirects: bool = False,
336
+ ) -> tuple[bytes | None, str | None]:
337
+ ref_fetch = _ref_for_fetch(ref)
338
+ show_spec = _show_spec_for_ref(path)
339
+ work_dir = f"{temp_dir}/{uuid.uuid4()}"
340
+ Path(work_dir).mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
341
+
342
+ url = format_url(
343
+ repo_url=repo_url,
344
+ user=user,
345
+ password=password,
346
+ token=token,
347
+ provider=provider,
348
+ is_pat=is_pat,
349
+ )
350
+ config_args = get_https_git_config_args(
351
+ follow_redirects=follow_redirects,
352
+ is_pat=is_pat,
353
+ token=token,
354
+ disable_credential_prompt=token is not None or password is not None,
355
+ )
356
+ env = os.environ.copy()
357
+ if token is not None or password is not None:
358
+ env["GIT_TERMINAL_PROMPT"] = "0"
359
+
360
+ try:
361
+ return await _try_show_strategies(
362
+ env,
363
+ config_args,
364
+ url,
365
+ ref_fetch,
366
+ show_spec,
367
+ path,
368
+ work_dir,
369
+ temp_dir,
370
+ )
371
+ finally:
372
+ shutil.rmtree(work_dir, ignore_errors=True)
373
+
374
+
375
+ async def show_file_at_ref( # noqa: PLR0913
376
+ repo_url: str,
377
+ ref: str,
378
+ path: str,
379
+ temp_dir: str,
380
+ *,
381
+ credential_key: str | None = None,
382
+ user: str | None = None,
383
+ password: str | None = None,
384
+ token: str | None = None,
385
+ provider: str | None = None,
386
+ is_pat: bool = False,
387
+ follow_redirects: bool = False,
388
+ ) -> tuple[bytes | None, str | None]:
389
+ if credential_key:
390
+ return await show_file_at_ref_ssh(
391
+ repo_url,
392
+ ref,
393
+ path,
394
+ temp_dir,
395
+ credential_key=credential_key,
396
+ )
397
+ return await show_file_at_ref_https(
398
+ repo_url,
399
+ ref,
400
+ path,
401
+ temp_dir,
402
+ user=user,
403
+ password=password,
404
+ token=token,
405
+ provider=provider,
406
+ is_pat=is_pat,
407
+ follow_redirects=follow_redirects,
408
+ )
@@ -0,0 +1,214 @@
1
+ import asyncio
2
+ import base64
3
+ import ipaddress
4
+ import os
5
+ import tempfile
6
+ import uuid
7
+ from collections.abc import Generator
8
+ from contextlib import contextmanager, suppress
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ from fluidattacks_core.http.validations import (
13
+ HTTPValidationError,
14
+ validate_local_request,
15
+ )
16
+
17
+ from fluidattacks_core.git.utils import run_git
18
+
19
+
20
+ class SSHHostValidationError(Exception):
21
+ pass
22
+
23
+
24
+ def _extract_hostname_from_ssh_url(url: str) -> str | None:
25
+ if "://" in url:
26
+ parsed = urlparse(url)
27
+ if parsed.hostname:
28
+ return parsed.hostname
29
+ if "@" in url and ":" in url:
30
+ host_part = url.split("@", maxsplit=1)[-1]
31
+ return host_part.split(":", maxsplit=1)[0]
32
+ return None
33
+
34
+
35
+ async def validate_ssh_host(
36
+ url: str,
37
+ *,
38
+ allow_local_network: bool = False,
39
+ allow_localhost: bool = False,
40
+ ) -> None:
41
+ hostname = _extract_hostname_from_ssh_url(parse_ssh_url(url))
42
+ if not hostname:
43
+ return
44
+
45
+ loop = asyncio.get_running_loop()
46
+ infos = await loop.getaddrinfo(hostname, None)
47
+
48
+ seen: set[str] = set()
49
+ ips: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
50
+ for _family, _type, _proto, _canonname, sockaddr in infos:
51
+ addr = sockaddr[0]
52
+ if addr not in seen:
53
+ seen.add(addr)
54
+ ips.append(ipaddress.ip_address(addr))
55
+
56
+ if not ips:
57
+ msg = f"No IP addresses resolved for hostname: {hostname}"
58
+ raise SSHHostValidationError(msg)
59
+
60
+ try:
61
+ validate_local_request(
62
+ ips,
63
+ allow_local_network=allow_local_network,
64
+ allow_localhost=allow_localhost,
65
+ )
66
+ except HTTPValidationError as exc:
67
+ msg = f"SSH host validation failed for {hostname}: {exc}"
68
+ raise SSHHostValidationError(msg) from exc
69
+
70
+
71
+ def _add_ssh_scheme_to_url(url: str) -> str:
72
+ scheme: str = "ssh://"
73
+ if url.startswith(scheme):
74
+ return url
75
+ return scheme + url
76
+
77
+
78
+ def _url_has_port(url: str) -> bool:
79
+ parsed_url = urlparse(url)
80
+ try:
81
+ if parsed_url.port:
82
+ return True
83
+ except ValueError:
84
+ # Port could not be cast to integer value, or
85
+ # Port out of range 0-65535
86
+ return False
87
+ else:
88
+ return False
89
+
90
+
91
+ def _set_default_ssh_port(url_with_scheme: str) -> str:
92
+ """Add a default port placeholder to a URL that lacks an explicit port.
93
+
94
+ This function modifies URLs that use the SSH protocol format for Git
95
+ repositories. It adds a placeholder for the default port
96
+ (represented by ':/') after the hostname.
97
+
98
+ Args:
99
+ url_with_scheme (str): The input URL, expected to be in the format
100
+ "ssh://git@hostname:path/to/repo.git"
101
+
102
+ Returns:
103
+ str: The modified URL with the default port placeholder added,
104
+ in the format "ssh://git@hostname:/path/to/repo.git"
105
+
106
+ Examples:
107
+ "ssh://git@gitlab.com:fluidattacks/demo.git" becomes
108
+ "ssh://git@gitlab.com:/fluidattacks/demo.git"
109
+
110
+ Note:
111
+ This function modifies the URL only if all the following
112
+ conditions are met:
113
+ 1. The URL starts with 'ssh://'.
114
+ 2. The URL does not already contain a port.
115
+ 3. The URL contains exactly two colons after the 'ssh://' scheme.
116
+ URLs not meeting these criteria are returned unchanged.
117
+
118
+ """
119
+ has_ssh_scheme = url_with_scheme.startswith("ssh://")
120
+
121
+ # formatting is skipped if no ssh scheme or URL contains a port
122
+ if not has_ssh_scheme or _url_has_port(url_with_scheme):
123
+ return url_with_scheme
124
+
125
+ url_parts = url_with_scheme.split(":", 2)
126
+ if len(url_parts) < 3:
127
+ return url_with_scheme
128
+
129
+ return f"{url_parts[0]}:{url_parts[1]}:/{url_parts[2]}"
130
+
131
+
132
+ def parse_ssh_url(url: str) -> str:
133
+ if "source.developers.google" in url or url.startswith("ssh://FLUID"):
134
+ return url
135
+
136
+ url_with_scheme = _add_ssh_scheme_to_url(url)
137
+
138
+ # url misses an explicit ssh port
139
+ return _set_default_ssh_port(url_with_scheme)
140
+
141
+
142
+ def get_ssh_git_env(ssh_key_path: str | Path) -> dict[str, str]:
143
+ return {
144
+ **os.environ.copy(),
145
+ "GIT_SSH_COMMAND": (
146
+ f"ssh -i {ssh_key_path}"
147
+ " -o UserKnownHostsFile=/dev/null"
148
+ " -o StrictHostKeyChecking=no"
149
+ " -o IdentitiesOnly=yes"
150
+ " -o HostkeyAlgorithms=+ssh-rsa"
151
+ " -o PubkeyAcceptedAlgorithms=+ssh-rsa"
152
+ ),
153
+ }
154
+
155
+
156
+ def create_ssh_key_file(temp_dir: str, credential_key: str) -> str:
157
+ ssh_file_name: str = os.path.join(temp_dir, str(uuid.uuid4())) # noqa: PTH118
158
+ with open( # noqa: PTH123
159
+ os.open(ssh_file_name, os.O_CREAT | os.O_WRONLY, 0o400),
160
+ "w",
161
+ encoding="utf-8",
162
+ ) as ssh_file:
163
+ ssh_file.write(base64.b64decode(credential_key).decode())
164
+ return ssh_file_name
165
+
166
+
167
+ @contextmanager
168
+ def ssh_git_env_context(
169
+ temp_dir: str, credential_key: str
170
+ ) -> Generator[dict[str, str], None, None]:
171
+ ssh_key_path = create_ssh_key_file(temp_dir, credential_key)
172
+ try:
173
+ yield get_ssh_git_env(ssh_key_path)
174
+ finally:
175
+ with suppress(OSError):
176
+ os.remove(ssh_key_path) # noqa: PTH107
177
+
178
+
179
+ async def ssh_ls_remote(
180
+ repo_url: str,
181
+ credential_key: str,
182
+ branch: str,
183
+ ) -> tuple[str | None, str | None]:
184
+ raw_root_url = parse_ssh_url(repo_url)
185
+ with (
186
+ tempfile.TemporaryDirectory() as temp_dir,
187
+ ssh_git_env_context(temp_dir, credential_key) as env,
188
+ ):
189
+ try:
190
+ stdout, stderr, returncode = await run_git(
191
+ "ls-remote",
192
+ "--",
193
+ raw_root_url,
194
+ branch,
195
+ env=env,
196
+ timeout=20,
197
+ )
198
+ except asyncio.exceptions.TimeoutError:
199
+ return None, "git ls-remote time out"
200
+ if returncode == 0:
201
+ return stdout.decode().split("\t")[0], None
202
+ return None, stderr.decode("utf-8")
203
+
204
+
205
+ async def call_ssh_ls_remote(
206
+ repo_url: str,
207
+ credential_key: str,
208
+ branch: str,
209
+ ) -> tuple[str | None, str | None]:
210
+ return await ssh_ls_remote(
211
+ repo_url=repo_url,
212
+ credential_key=credential_key,
213
+ branch=branch,
214
+ )