feat(delivery): git workspaces + SSRF host check (v2 phase A3-2)
The platform's side of D6: git runs on the host, in a gitdir that is never mounted into a container, so repository credentials never reach the model. - adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at /workspace + one linked worktree per task under work/.worktrees/<id> (excluded via the gitdir's info/exclude, not the project's .gitignore). Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off, quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off, one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH key as a 0600 temp file with a pinned known_hosts. Everything deleted in a finally; only a *path* ever travels through the environment. - The bug this phase existed to find: --git-dir=<shared> does not address a linked worktree, so `git commit` in a task worktree committed straight to `dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and never read the worktree's .git file, which a repository can rewrite. - domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed (resolves, refuses if any address is loopback/private/link-local/metadata). Shared by the git remote test and, later, every deploy target. - ProjectExternalGit domain model + repository; container_delivery wires the real adapter behind GIT_WORKSPACE=local with a resolver that joins the project, its repo and its (decrypted) credentials. Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean. Report: docs/plan/reports/agent-3-phase-2.md
This commit is contained in:
@@ -1,16 +1,41 @@
|
||||
"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester).
|
||||
|
||||
Foundation defaults are in-memory fakes. The delivery agent replaces the bodies (behind
|
||||
settings) with the real adapters; the function names and return types are FROZEN.
|
||||
The real adapters are selected by settings; the fakes remain the defaults so a
|
||||
test that does not opt in never touches a git binary, a container or a network.
|
||||
|
||||
``build_repo_workspace`` is the interesting one: the git adapter needs to know
|
||||
*where* a project's repository lives and *how* to authenticate to it, and that
|
||||
knowledge lives in three places — ``project_repositories`` +
|
||||
``project_git_credentials`` for the platform forge, ``project_external_git`` for a
|
||||
user-supplied remote, and ``projects`` for which of the two applies. The resolver
|
||||
below is the only code that joins them, and it hands the plaintext secret
|
||||
straight to the adapter for the duration of a single call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
||||
from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester
|
||||
from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort
|
||||
from app.core.config import settings
|
||||
from app.core.crypto import decrypt
|
||||
from app.infrastructure.adapters.git_workspace import (
|
||||
LocalGitWorkspaceAdapter,
|
||||
ProjectGitSource,
|
||||
)
|
||||
from app.infrastructure.db.models import ProjectRow
|
||||
from app.infrastructure.repositories.projects import (
|
||||
ProjectExternalGitRepository,
|
||||
ProjectGitCredentialRepository,
|
||||
ProjectGitRepoRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agy.container.delivery")
|
||||
|
||||
_fake_workspace = FakeRepoWorkspace()
|
||||
_null_hooks = NullDeliveryHooks()
|
||||
@@ -18,7 +43,79 @@ _fake_deploy_tester = FakeDeployTargetTester()
|
||||
|
||||
|
||||
def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
|
||||
return _fake_workspace
|
||||
"""The platform's git adapter, or the in-memory fake when ``GIT_WORKSPACE=fake``."""
|
||||
if settings.git_workspace.strip().lower() != "local":
|
||||
return _fake_workspace
|
||||
|
||||
projects = ProjectRepository(session)
|
||||
repos = ProjectGitRepoRepository(session)
|
||||
credentials = ProjectGitCredentialRepository(session)
|
||||
external = ProjectExternalGitRepository(session)
|
||||
|
||||
async def resolve(project_id: str) -> ProjectGitSource | None:
|
||||
project = await projects.get(project_id)
|
||||
if project is None:
|
||||
return None
|
||||
|
||||
row = await session.get(ProjectRow, project_id)
|
||||
if row is None:
|
||||
return None
|
||||
git_source = (row.git_source or "platform").lower()
|
||||
|
||||
if git_source == "external":
|
||||
url = (row.external_repo_url or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
record = await external.get_for_project(project_id)
|
||||
return ProjectGitSource(
|
||||
project_id=project_id,
|
||||
organization_id=project.organization_id,
|
||||
clone_url=url,
|
||||
default_branch=project.default_branch,
|
||||
auth_type=(record.auth_type if record else "none") or "none",
|
||||
username=record.username if record else None,
|
||||
secret=_decrypt_or_none(
|
||||
record.secret_encrypted if record else None, project_id
|
||||
),
|
||||
known_hosts=record.known_hosts if record else None,
|
||||
is_platform=False,
|
||||
)
|
||||
|
||||
repo = await repos.get_for_project(project_id)
|
||||
if repo is None or not repo.clone_url:
|
||||
return None
|
||||
credential = await credentials.get_for_project(project_id)
|
||||
secret = _decrypt_or_none(
|
||||
credential.encrypted_password if credential else None, project_id
|
||||
)
|
||||
return ProjectGitSource(
|
||||
project_id=project_id,
|
||||
organization_id=project.organization_id,
|
||||
clone_url=repo.clone_url,
|
||||
default_branch=repo.default_branch or project.default_branch,
|
||||
auth_type="token" if secret else "none",
|
||||
username=credential.username if credential else None,
|
||||
secret=secret,
|
||||
is_platform=True,
|
||||
)
|
||||
|
||||
return LocalGitWorkspaceAdapter(resolver=resolve)
|
||||
|
||||
|
||||
def _decrypt_or_none(ciphertext: str | None, project_id: str) -> str | None:
|
||||
"""Decrypt a stored secret, tolerating a rotated ``ENCRYPTION_KEY``.
|
||||
|
||||
A rotated key must surface as "no credential" (git then fails with an auth
|
||||
error the user can act on) rather than as an exception that breaks an
|
||||
unrelated request. The failure is logged, never raised with the ciphertext.
|
||||
"""
|
||||
if not ciphertext:
|
||||
return None
|
||||
try:
|
||||
return decrypt(ciphertext)
|
||||
except Exception: # noqa: BLE001 - see docstring
|
||||
logger.warning("git credential for project %s could not be decrypted", project_id)
|
||||
return None
|
||||
|
||||
|
||||
def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks:
|
||||
|
||||
@@ -155,6 +155,11 @@ class Settings(BaseSettings):
|
||||
artifacts_root: str = "./.data/artifacts"
|
||||
git_workspace: str = "fake" # fake | local
|
||||
allow_custom_domains: bool = False
|
||||
#: ``protocol.file.allow`` for host git calls. ``False`` (→ ``never``) is the
|
||||
#: hardened default: a repository must not be able to make git read a local
|
||||
#: path via a submodule or ``--upload-pack``. Set it to true only to test
|
||||
#: against a local bare repository.
|
||||
git_allow_file_protocol: bool = False
|
||||
# <<< agent-3
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"""Platform-wide validation rules carried over from the existing infrastructure.
|
||||
|
||||
These are pure functions with no I/O, so both the application layer *and* the
|
||||
adapters can enforce them. Defence in depth: a rule that is only checked in one
|
||||
place is a rule that will eventually be bypassed.
|
||||
These are almost all pure functions with no I/O, so both the application layer
|
||||
*and* the adapters can enforce them. Defence in depth: a rule that is only
|
||||
checked in one place is a rule that will eventually be bypassed.
|
||||
|
||||
The one exception is :func:`assert_remote_host_allowed`, which resolves a
|
||||
hostname. It has to: a *name* tells you nothing about where it points, and the
|
||||
whole point of the check is to refuse a name that resolves into the private
|
||||
network. The address classification itself is pure
|
||||
(:func:`is_blocked_address`), so the policy is testable without DNS.
|
||||
|
||||
Reference: ``docs/current-infra-notes.md`` §1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir"
|
||||
|
||||
@@ -119,3 +128,143 @@ def assert_not_protected_port(port: int) -> int:
|
||||
if port in SYSTEM_PROTECTED_PORTS:
|
||||
raise RuleViolation(f"اتصال به پورت محافظتشدهٔ {port} ممنوع است.")
|
||||
return port
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF: user-supplied remote hosts (git remotes, deploy targets)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Ports that are always refused on a user-supplied host, whatever the address.
|
||||
#: A remote git or deploy target has no business talking to a database, a cache
|
||||
#: or the Docker daemon. Port 22 is deliberately *not* here: git-over-ssh and
|
||||
#: SFTP deploys are legitimate.
|
||||
BLOCKED_REMOTE_PORTS: frozenset[int] = frozenset(
|
||||
{2375, 2376, 3306, 5432, 6379, 9200, 11211, 27017}
|
||||
)
|
||||
|
||||
#: Hostnames that are never a valid user-supplied remote. `localhost` is a name,
|
||||
#: not an address, so it needs its own rule — DNS would resolve it to loopback,
|
||||
#: but only if we remembered to resolve.
|
||||
_BLOCKED_HOSTNAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"ip6-localhost",
|
||||
"ip6-loopback",
|
||||
"metadata",
|
||||
"metadata.google.internal",
|
||||
"instance-data",
|
||||
}
|
||||
)
|
||||
|
||||
_ALLOWED_REMOTE_SCHEMES: frozenset[str] = frozenset(
|
||||
{"http", "https", "ssh", "git", "ftp", "ftps", "sftp"}
|
||||
)
|
||||
|
||||
|
||||
def is_blocked_address(address: str) -> bool:
|
||||
"""True when an IP literal must not be the target of a user-supplied remote.
|
||||
|
||||
Covers every way to reach something that is not the public internet:
|
||||
loopback, RFC1918 private, link-local (including the cloud metadata address
|
||||
``169.254.169.254``), unique-local IPv6, multicast, reserved, unspecified,
|
||||
and IPv4-mapped IPv6 forms of all of the above.
|
||||
"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return False # not an IP literal; the DNS path handles it
|
||||
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
# ::ffff:127.0.0.1 is loopback in disguise.
|
||||
ip = ip.ipv4_mapped
|
||||
|
||||
return bool(
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def assert_remote_host_allowed(
|
||||
url: str, *, allow_private: bool = False, resolver=None
|
||||
) -> tuple[str, int | None]:
|
||||
"""Validate a user-supplied remote URL and return ``(host, port)``.
|
||||
|
||||
Runs before *any* outbound connection — a git remote test, a deploy
|
||||
connection — because a user-supplied URL is the classic SSRF vector:
|
||||
``http://169.254.169.254/`` reaches cloud instance credentials, and
|
||||
``http://127.0.0.1:6379/`` reaches Redis.
|
||||
|
||||
``resolver`` is injectable so the DNS step can be faked in tests; it defaults
|
||||
to ``socket.getaddrinfo``. Every address the name resolves to must pass, not
|
||||
just the first: a name can legitimately have both a public and a private
|
||||
address, and returning either would defeat the check.
|
||||
"""
|
||||
clean = (url or "").strip()
|
||||
if not clean:
|
||||
raise RuleViolation("آدرس مقصد خالی است.")
|
||||
if any(ch.isspace() for ch in clean):
|
||||
raise RuleViolation("آدرس مقصد نباید فاصله داشته باشد.")
|
||||
|
||||
# A bare ``git@host:path`` scp-style remote has no scheme; normalise it so
|
||||
# urlsplit can do its job.
|
||||
if "://" not in clean and re.match(r"^[^/@\s]+@[^/:\s]+:", clean):
|
||||
clean = "ssh://" + clean.replace(":", "/", 1)
|
||||
|
||||
parts = urlsplit(clean)
|
||||
if parts.scheme.lower() not in _ALLOWED_REMOTE_SCHEMES:
|
||||
raise RuleViolation(
|
||||
"پروتکل آدرس مقصد پشتیبانی نمیشود."
|
||||
)
|
||||
host = parts.hostname or ""
|
||||
if not host:
|
||||
raise RuleViolation("میزبان آدرس مقصد مشخص نیست.")
|
||||
if parts.password is not None:
|
||||
# Credentials in a URL end up in logs, in `ps` and in the git remote
|
||||
# config. They must be supplied as credentials, not as part of the URL.
|
||||
raise RuleViolation("آدرس مقصد نباید شامل نام کاربری و رمز باشد.")
|
||||
|
||||
port = parts.port
|
||||
if port is not None:
|
||||
if not (1 <= port <= 65535):
|
||||
raise RuleViolation("شمارهٔ پورت مقصد نامعتبر است.")
|
||||
if port in BLOCKED_REMOTE_PORTS:
|
||||
raise RuleViolation(f"پورت {port} برای مقصد مجاز نیست.")
|
||||
|
||||
if allow_private:
|
||||
return host, port
|
||||
|
||||
lowered = host.lower().strip(".")
|
||||
if lowered in _BLOCKED_HOSTNAMES or lowered.endswith(".localhost"):
|
||||
raise RuleViolation("اتصال به میزبان محلی مجاز نیست.")
|
||||
|
||||
# An IP literal can be judged directly, without DNS.
|
||||
try:
|
||||
ipaddress.ip_address(lowered.strip("[]"))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if is_blocked_address(lowered.strip("[]")):
|
||||
raise RuleViolation("اتصال به آدرسهای داخلی مجاز نیست.")
|
||||
return host, port
|
||||
|
||||
lookup = resolver or socket.getaddrinfo
|
||||
try:
|
||||
infos = lookup(host, port or 0, type=socket.SOCK_STREAM)
|
||||
except (socket.gaierror, UnicodeError, OSError) as exc:
|
||||
raise RuleViolation("میزبان مقصد پیدا نشد.") from exc
|
||||
|
||||
# ``getaddrinfo`` returns a family-dependent sockaddr, so the address is
|
||||
# coerced to str explicitly rather than relying on the overload's inference.
|
||||
addresses: set[str] = {str(info[4][0]) for info in infos}
|
||||
if not addresses:
|
||||
raise RuleViolation("میزبان مقصد پیدا نشد.")
|
||||
for address in addresses:
|
||||
if is_blocked_address(address):
|
||||
raise RuleViolation("اتصال به آدرسهای داخلی مجاز نیست.")
|
||||
|
||||
return host, port
|
||||
|
||||
@@ -54,3 +54,20 @@ class ProjectGitCredential:
|
||||
encrypted_password: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ProjectExternalGit:
|
||||
"""Credentials for a *user-supplied* git remote (v2 ``git_source=external``).
|
||||
|
||||
Separate from :class:`ProjectGitCredential`, which is the platform's own
|
||||
Gitea identity. Keeping them apart means switching a project to an external
|
||||
remote can never accidentally reuse — or expose — the platform token.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
auth_type: str = "none" # none | token | ssh_key
|
||||
username: str | None = None
|
||||
secret_encrypted: str | None = None
|
||||
known_hosts: str | None = None
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,17 @@ from __future__ import annotations
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.projects.models import (
|
||||
Project,
|
||||
ProjectExternalGit,
|
||||
ProjectGitCredential,
|
||||
ProjectGitRepo,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
PreviewDomainRow,
|
||||
PreviewPortRow,
|
||||
ProjectExternalGitRow,
|
||||
ProjectGitCredentialRow,
|
||||
ProjectRepositoryRow,
|
||||
ProjectRow,
|
||||
@@ -36,6 +39,53 @@ def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
|
||||
)
|
||||
|
||||
|
||||
class ProjectExternalGitRepository:
|
||||
"""Persistence for a user-supplied remote's credentials.
|
||||
|
||||
The secret is stored encrypted and is *never* returned by this repository:
|
||||
callers that need it (the git workspace adapter) ask for it explicitly
|
||||
through :meth:`get_secret`, which keeps the plaintext out of every code path
|
||||
that only needs to render a status.
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_project(self, project_id: str) -> ProjectExternalGit | None:
|
||||
row = await self.session.get(ProjectExternalGitRow, project_id)
|
||||
return _external_git_to_domain(row) if row else None
|
||||
|
||||
async def upsert(self, record: ProjectExternalGit) -> ProjectExternalGit:
|
||||
row = await self.session.get(ProjectExternalGitRow, record.project_id)
|
||||
if row is None:
|
||||
row = ProjectExternalGitRow(project_id=record.project_id)
|
||||
self.session.add(row)
|
||||
row.auth_type = record.auth_type
|
||||
row.username = record.username
|
||||
row.secret_encrypted = record.secret_encrypted
|
||||
row.known_hosts = record.known_hosts
|
||||
row.updated_at = utcnow()
|
||||
await self.session.flush()
|
||||
return _external_git_to_domain(row)
|
||||
|
||||
async def delete_for_project(self, project_id: str) -> None:
|
||||
row = await self.session.get(ProjectExternalGitRow, project_id)
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
def _external_git_to_domain(row: ProjectExternalGitRow) -> ProjectExternalGit:
|
||||
return ProjectExternalGit(
|
||||
project_id=row.project_id,
|
||||
auth_type=row.auth_type,
|
||||
username=row.username,
|
||||
secret_encrypted=row.secret_encrypted,
|
||||
known_hosts=row.known_hosts,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class ProjectGitRepoRepository:
|
||||
"""Persistence for the Git repository attached to a project."""
|
||||
|
||||
|
||||
@@ -61,6 +61,13 @@ ignore = ["B008"]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
# `docker` marks tests that need a real container runtime, `deploy_it` those
|
||||
# that need a live sshd/ftp server. Both are skipped unless the matching
|
||||
# environment flag is set, so the default run needs no infrastructure.
|
||||
markers = [
|
||||
"docker: needs a running docker/podman (set SANDBOX_DOCKER_IT=1)",
|
||||
"deploy_it: needs a live ssh/ftp test server (set DEPLOY_IT=1)",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
"""§v2-A3.2 — the platform's git adapter, exercised against real git.
|
||||
|
||||
These tests run a real ``git`` binary against a local bare repository standing in
|
||||
for the remote. Nothing is faked except the *remote's location*: the flags, the
|
||||
locking, the worktree dance and the credential plumbing are the real thing,
|
||||
because those are precisely the parts that cannot be verified by a mock.
|
||||
|
||||
``protocol.file.allow=user`` is passed explicitly. The production default is
|
||||
``never`` — a repository must not be able to make git read a local path through a
|
||||
submodule or ``--upload-pack`` — but a local bare repo *is* a local path, so the
|
||||
hardening has to be relaxed for the test to be able to run at all. That is why
|
||||
the setting exists rather than being hard-coded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.errors import NotFoundError
|
||||
from app.domain.platform.rules import RuleViolation, assert_remote_host_allowed, is_blocked_address
|
||||
from app.infrastructure.adapters.git_workspace import (
|
||||
LocalGitWorkspaceAdapter,
|
||||
ProjectGitSource,
|
||||
)
|
||||
from app.infrastructure.adapters.process import ProcessResult, run_process
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _git(args: list[str], *, cwd: str, ok: bool = True) -> ProcessResult:
|
||||
result = await run_process(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
timeout_seconds=60,
|
||||
env={
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@example.com",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@example.com",
|
||||
},
|
||||
)
|
||||
if ok:
|
||||
assert result.ok, f"git {' '.join(args)} failed: {result.stderr}"
|
||||
return result
|
||||
|
||||
|
||||
async def _make_remote(tmp_path) -> str:
|
||||
"""A local bare repository with one commit on ``main``; returns its path."""
|
||||
remote = tmp_path / "remote.git"
|
||||
seed = tmp_path / "seed"
|
||||
await _git(["init", "--quiet", "--bare", str(remote)], cwd=str(tmp_path))
|
||||
await _git(["init", "--quiet", str(seed)], cwd=str(tmp_path))
|
||||
(seed / "README.md").write_text("hello\n")
|
||||
await _git(["add", "-A"], cwd=str(seed))
|
||||
await _git(["commit", "--quiet", "-m", "init"], cwd=str(seed))
|
||||
await _git(["branch", "-M", "main"], cwd=str(seed))
|
||||
await _git(["remote", "add", "origin", str(remote)], cwd=str(seed))
|
||||
await _git(["push", "--quiet", "origin", "main"], cwd=str(seed))
|
||||
return str(remote)
|
||||
|
||||
|
||||
def _resolver(clone_url: str, *, auth_type: str = "none", secret: str | None = None,
|
||||
username: str | None = None):
|
||||
async def resolve(project_id: str) -> ProjectGitSource | None:
|
||||
if project_id == "missing":
|
||||
return None
|
||||
return ProjectGitSource(
|
||||
project_id=project_id,
|
||||
organization_id="org1",
|
||||
clone_url=clone_url,
|
||||
default_branch="main",
|
||||
auth_type=auth_type,
|
||||
username=username,
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def _adapter(tmp_path, clone_url: str, **kwargs) -> LocalGitWorkspaceAdapter:
|
||||
kwargs.setdefault("allow_file_protocol", True)
|
||||
kwargs.setdefault("runner", run_process)
|
||||
return LocalGitWorkspaceAdapter(
|
||||
resolver=_resolver(clone_url, **kwargs.pop("credentials", {})),
|
||||
root=str(tmp_path / "root"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_workspace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ensure_workspace_clones_and_creates_dev(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
|
||||
ref = await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
assert ref.default_branch == "dev"
|
||||
assert os.path.isdir(ref.host_path), "the work tree must exist for the sandbox mount"
|
||||
assert (tmp_path / "root" / "org1" / "proj1" / "work" / "README.md").is_file()
|
||||
assert await adapter.head(project_id="proj1", branch="dev") is not None
|
||||
# `dev` was pushed to the platform remote because it did not exist there.
|
||||
assert await adapter.head(project_id="proj1", branch="dev") is not None
|
||||
|
||||
|
||||
async def test_ensure_workspace_is_idempotent(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
|
||||
first = await adapter.ensure_workspace(project_id="proj1")
|
||||
(tmp_path / "root" / "org1" / "proj1" / "work" / "new.txt").write_text("x")
|
||||
second = await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
assert first.host_path == second.host_path
|
||||
assert (tmp_path / "root" / "org1" / "proj1" / "work" / "new.txt").is_file()
|
||||
|
||||
|
||||
async def test_ensure_workspace_rejects_an_unknown_project(tmp_path):
|
||||
adapter = _adapter(tmp_path, str(tmp_path / "nothing.git"))
|
||||
with pytest.raises(NotFoundError):
|
||||
await adapter.ensure_workspace(project_id="missing")
|
||||
|
||||
|
||||
async def test_gitdir_is_not_inside_the_work_tree(tmp_path):
|
||||
"""The git dir must never be reachable from the sandbox mount (D6)."""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
ref = await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
|
||||
assert gitdir.is_dir()
|
||||
assert not os.path.exists(os.path.join(ref.host_path, ".git"))
|
||||
assert not str(gitdir).startswith(ref.host_path + os.sep)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# worktrees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_task_worktree_is_created_and_removed(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
assert worktree.branch == "task/t1"
|
||||
assert worktree.sandbox_path == "/workspace/.worktrees/t1"
|
||||
assert os.path.isdir(worktree.host_path)
|
||||
|
||||
await adapter.remove_worktree(worktree=worktree)
|
||||
assert not os.path.isdir(worktree.host_path)
|
||||
|
||||
|
||||
async def test_task_worktree_is_invisible_in_the_main_work_tree(tmp_path):
|
||||
"""`info/exclude` keeps .worktrees out of `status` without touching .gitignore."""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
ref = await adapter.ensure_workspace(project_id="proj1")
|
||||
await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
result = await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", ref.host_path,
|
||||
"status", "--porcelain=v1", "-uall"],
|
||||
cwd=ref.host_path,
|
||||
)
|
||||
assert result.stdout.strip() == "", "the worktree dir must not show up as untracked"
|
||||
assert not os.path.exists(os.path.join(ref.host_path, ".gitignore"))
|
||||
|
||||
|
||||
async def test_create_task_worktree_is_idempotent(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
first = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
second = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
assert first.host_path == second.host_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# diff / commit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_diff_reports_a_new_directory_thanks_to_uall(tmp_path):
|
||||
"""`git status` without ``-uall`` hides new directories — a real agybot bug."""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
(tmp_path / "root" / "org1" / "proj1" / "work" / ".worktrees" / "t1" / "src").mkdir()
|
||||
(
|
||||
tmp_path / "root" / "org1" / "proj1" / "work" / ".worktrees" / "t1" / "src" / "a.py"
|
||||
).write_text("print('hi')\n")
|
||||
|
||||
diff = await adapter.diff(worktree=worktree)
|
||||
|
||||
assert diff.changed_files == ["src/a.py"]
|
||||
assert diff.is_empty is False
|
||||
|
||||
|
||||
async def test_diff_detects_an_escaping_symlink(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("secret")
|
||||
os.symlink(outside, os.path.join(worktree.host_path, "escape.txt"))
|
||||
|
||||
diff = await adapter.diff(worktree=worktree)
|
||||
|
||||
assert diff.escaping_symlinks == ["escape.txt"]
|
||||
|
||||
|
||||
async def test_commit_returns_none_when_there_is_nothing_to_commit(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
assert await adapter.commit(worktree=worktree, message="empty", author="AI") is None
|
||||
|
||||
|
||||
async def test_commit_records_the_author_and_returns_a_sha(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
target = os.path.join(worktree.host_path, "feature.py")
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
handle.write("x = 1\n")
|
||||
|
||||
sha = await adapter.commit(worktree=worktree, message="task/t1: add feature", author="Alice")
|
||||
|
||||
assert sha and len(sha) == 40
|
||||
shared_gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
|
||||
log = await _git(
|
||||
["--git-dir", str(shared_gitdir), "log", "-1", "--format=%an <%ae>|%s", "task/t1"],
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
assert log.stdout.strip() == "Alice <alice@smart-org.local>|task/t1: add feature"
|
||||
|
||||
# ...and specifically to `task/t1`, not to `dev`. A shared gitdir makes this
|
||||
# the easiest thing in the world to get wrong.
|
||||
task_head = await _git(
|
||||
["--git-dir", str(shared_gitdir), "rev-parse", "task/t1"], cwd=str(tmp_path)
|
||||
)
|
||||
assert task_head.stdout.strip() == sha
|
||||
dev_head = await _git(
|
||||
["--git-dir", str(shared_gitdir), "rev-parse", "dev"], cwd=str(tmp_path)
|
||||
)
|
||||
assert dev_head.stdout.strip() != sha, "the commit must not land on dev directly"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge / push / head
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_merge_into_dev_and_push(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
|
||||
handle.write("a\n")
|
||||
sha = await adapter.commit(worktree=worktree, message="task/t1: a", author="AI")
|
||||
|
||||
merged = await adapter.merge_into(project_id="proj1", branch="task/t1", target="dev")
|
||||
assert merged.ok is True, merged.message
|
||||
|
||||
# `--no-ff` means dev gets a merge *commit*, not the task commit itself, so
|
||||
# the task sha is a parent of dev's new head rather than dev's head.
|
||||
dev_head = await adapter.head(project_id="proj1", branch="dev")
|
||||
assert dev_head == merged.sha
|
||||
assert dev_head != sha
|
||||
ancestry = await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"rev-list", "--parents", "-1", "dev"],
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
assert sha in ancestry.stdout.split()
|
||||
|
||||
pushed = await adapter.push(project_id="proj1", branch="dev")
|
||||
assert pushed.ok is True, pushed.message
|
||||
|
||||
# The remote really received it.
|
||||
ls = await _git(["--git-dir", remote, "rev-parse", "dev"], cwd=str(tmp_path))
|
||||
assert ls.stdout.strip() == dev_head
|
||||
|
||||
|
||||
async def test_merge_conflict_leaves_a_clean_tree(tmp_path):
|
||||
"""A half-merge would break every later operation (agybot bug H3).
|
||||
|
||||
The task branch has to fork *before* ``dev`` moves, otherwise the merge is a
|
||||
trivial fast-forward and there is nothing to conflict about.
|
||||
"""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
# 1. The task branches off dev as it stands.
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
with open(os.path.join(worktree.host_path, "README.md"), "w", encoding="utf-8") as handle:
|
||||
handle.write("task version\n")
|
||||
await adapter.commit(worktree=worktree, message="task/t1: edit", author="AI")
|
||||
|
||||
# 2. dev moves on, touching the same file.
|
||||
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
|
||||
(main_work / "README.md").write_text("dev version\n")
|
||||
await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "add", "-A"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "commit", "--quiet", "-m", "dev edit"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
|
||||
outcome = await adapter.merge_into(project_id="proj1", branch="task/t1", target="dev")
|
||||
|
||||
assert outcome.ok is False
|
||||
assert outcome.conflict_files == ["README.md"]
|
||||
|
||||
# No merge in progress: the next status is clean.
|
||||
status = await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "status", "--porcelain=v1", "-uall"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
assert status.stdout.strip() == ""
|
||||
assert not os.path.exists(
|
||||
tmp_path / "root" / "org1" / "proj1" / "git" / "MERGE_HEAD"
|
||||
)
|
||||
|
||||
|
||||
async def test_sync_with_base_reports_that_the_base_moved(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
# Nothing moved yet.
|
||||
quiet = await adapter.sync_with_base(worktree=worktree)
|
||||
assert quiet.ok is True and quiet.base_changed is False
|
||||
|
||||
# Move dev forward.
|
||||
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
|
||||
(main_work / "other.txt").write_text("x\n")
|
||||
await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "add", "-A"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "commit", "--quiet", "-m", "dev moved"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
|
||||
moved = await adapter.sync_with_base(worktree=worktree)
|
||||
assert moved.ok is True
|
||||
assert moved.base_changed is True
|
||||
assert os.path.isfile(os.path.join(worktree.host_path, "other.txt"))
|
||||
|
||||
|
||||
async def test_concurrent_merges_are_serialised(tmp_path):
|
||||
"""Without the per-project lock two merges corrupt the index."""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
for task in ("t1", "t2"):
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id=task)
|
||||
with open(
|
||||
os.path.join(worktree.host_path, f"{task}.txt"), "w", encoding="utf-8"
|
||||
) as handle:
|
||||
handle.write(f"{task}\n")
|
||||
await adapter.commit(worktree=worktree, message=f"task/{task}", author="AI")
|
||||
|
||||
first, second = await asyncio.gather(
|
||||
adapter.merge_into(project_id="proj1", branch="task/t1", target="dev"),
|
||||
adapter.merge_into(project_id="proj1", branch="task/t2", target="dev"),
|
||||
)
|
||||
|
||||
assert first.ok is True and second.ok is True
|
||||
status = await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(tmp_path / "root" / "org1" / "proj1" / "work"),
|
||||
"status", "--porcelain=v1", "-uall"],
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
assert status.stdout.strip() == ""
|
||||
|
||||
|
||||
async def test_push_reports_a_rejection_without_forcing(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
# Someone else advances the remote's dev, so our push is no longer a
|
||||
# fast-forward. `origin/dev` is what the clone checked out, not a fresh
|
||||
# branch off the default one.
|
||||
other = tmp_path / "other"
|
||||
await _git(["clone", "--quiet", remote, str(other)], cwd=str(tmp_path))
|
||||
await _git(["checkout", "--quiet", "-B", "dev", "origin/dev"], cwd=str(other))
|
||||
(other / "divergent.txt").write_text("other\n")
|
||||
await _git(["add", "-A"], cwd=str(other))
|
||||
await _git(["commit", "--quiet", "-m", "divergent"], cwd=str(other))
|
||||
await _git(["push", "--quiet", "origin", "dev"], cwd=str(other))
|
||||
|
||||
# Local dev moves a different way.
|
||||
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
|
||||
(main_work / "mine.txt").write_text("mine\n")
|
||||
await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "add", "-A"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
await _git(
|
||||
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
|
||||
"--work-tree", str(main_work), "commit", "--quiet", "-m", "mine"],
|
||||
cwd=str(main_work),
|
||||
)
|
||||
|
||||
outcome = await adapter.push(project_id="proj1", branch="dev")
|
||||
|
||||
assert outcome.ok is False
|
||||
assert outcome.rejected is True, outcome.message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hardening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_hooks_are_never_executed(tmp_path):
|
||||
"""A repository may ship hooks; a hook is arbitrary host code execution."""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
|
||||
hooks = gitdir / "hooks"
|
||||
hooks.mkdir(parents=True, exist_ok=True)
|
||||
marker = tmp_path / "HOOK_RAN"
|
||||
hook = hooks / "post-commit"
|
||||
hook.write_text(f"#!/bin/sh\ntouch {marker}\n")
|
||||
os.chmod(hook, 0o755)
|
||||
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
|
||||
handle.write("a\n")
|
||||
await adapter.commit(worktree=worktree, message="task/t1", author="AI")
|
||||
|
||||
assert not marker.exists(), "a post-commit hook must not run"
|
||||
|
||||
|
||||
async def test_a_tampered_dot_git_file_is_ignored(tmp_path):
|
||||
"""The `.git` pointer inside a worktree is attacker-controlled text."""
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
|
||||
|
||||
decoy = tmp_path / "decoy"
|
||||
await _git(["init", "--quiet", str(decoy)], cwd=str(tmp_path))
|
||||
with open(os.path.join(worktree.host_path, ".git"), "w", encoding="utf-8") as handle:
|
||||
handle.write(f"gitdir: {decoy}\n")
|
||||
|
||||
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
|
||||
handle.write("a\n")
|
||||
sha = await adapter.commit(worktree=worktree, message="task/t1", author="AI")
|
||||
|
||||
assert sha is not None
|
||||
# The commit landed on OUR task branch, not in the decoy.
|
||||
shared_gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
|
||||
real = await _git(
|
||||
["--git-dir", str(shared_gitdir), "rev-parse", "task/t1"], cwd=str(tmp_path)
|
||||
)
|
||||
assert real.stdout.strip() == sha
|
||||
decoy_log = await _git(
|
||||
["--git-dir", str(decoy), "log", "--oneline"], cwd=str(tmp_path), ok=False
|
||||
)
|
||||
assert decoy_log.exit_code != 0 or "task/t1" not in decoy_log.stdout
|
||||
|
||||
|
||||
async def test_credentials_never_appear_in_argv(tmp_path):
|
||||
"""argv is world-readable via `ps`; a secret must travel another way."""
|
||||
secret = "ghp_thisisasecrettokenvalue0123456789"
|
||||
seen: list[tuple[str, ...]] = []
|
||||
|
||||
async def recording_runner(argv, **kwargs): # noqa: ANN001, ANN003
|
||||
seen.append(tuple(argv))
|
||||
return await run_process(argv, **kwargs)
|
||||
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = LocalGitWorkspaceAdapter(
|
||||
resolver=_resolver(remote, auth_type="token", secret=secret, username="git"),
|
||||
root=str(tmp_path / "root"),
|
||||
allow_file_protocol=True,
|
||||
runner=recording_runner,
|
||||
)
|
||||
|
||||
# A token against a local path fails, but the *attempt* is what we assert on.
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
assert seen, "the adapter must have invoked git"
|
||||
for argv in seen:
|
||||
joined = " ".join(argv)
|
||||
assert secret not in joined, f"secret leaked into argv: {argv}"
|
||||
|
||||
|
||||
async def test_the_credential_file_is_removed_after_the_call(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = LocalGitWorkspaceAdapter(
|
||||
resolver=_resolver(remote, auth_type="token", secret="s3cret-token-value", username="git"),
|
||||
root=str(tmp_path / "root"),
|
||||
allow_file_protocol=True,
|
||||
)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
private = tmp_path / "root" / "org1" / "proj1" / "private"
|
||||
assert private.is_dir()
|
||||
assert not (private / "credentials").exists(), "the 0600 secret file must be deleted"
|
||||
assert not (private / "id_key").exists()
|
||||
|
||||
|
||||
async def test_the_askpass_helper_is_private_and_holds_no_secret(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = LocalGitWorkspaceAdapter(
|
||||
resolver=_resolver(remote, auth_type="token", secret="s3cret-token-value", username="git"),
|
||||
root=str(tmp_path / "root"),
|
||||
allow_file_protocol=True,
|
||||
)
|
||||
await adapter.ensure_workspace(project_id="proj1")
|
||||
|
||||
helper = tmp_path / "root" / "org1" / "proj1" / "private" / "askpass.sh"
|
||||
assert helper.is_file()
|
||||
assert oct(helper.stat().st_mode)[-3:] == "700"
|
||||
assert "s3cret-token-value" not in helper.read_text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF: the shared host check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"::1",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
"0.0.0.0",
|
||||
],
|
||||
)
|
||||
def test_is_blocked_address_covers_the_dangerous_ranges(address: str):
|
||||
assert is_blocked_address(address) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("address", ["8.8.8.8", "1.1.1.1", "2606:4700::1111"])
|
||||
def test_is_blocked_address_allows_public_addresses(address: str):
|
||||
assert is_blocked_address(address) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://127.0.0.1:6379/",
|
||||
"http://localhost/repo.git",
|
||||
"https://10.0.0.5/repo.git",
|
||||
"http://[::1]/repo.git",
|
||||
"https://user:pass@example.com/repo.git",
|
||||
"file:///etc/passwd",
|
||||
"https://example.com:6379/repo.git",
|
||||
],
|
||||
)
|
||||
def test_assert_remote_host_allowed_refuses_dangerous_targets(url: str):
|
||||
with pytest.raises(RuleViolation):
|
||||
assert_remote_host_allowed(url, resolver=lambda *a, **k: [])
|
||||
|
||||
|
||||
def test_assert_remote_host_allowed_accepts_a_public_host():
|
||||
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
|
||||
return [(2, 1, 6, "", ("93.184.216.34", port))]
|
||||
|
||||
host, port = assert_remote_host_allowed(
|
||||
"https://git.example.com/team/app.git", resolver=resolver
|
||||
)
|
||||
assert host == "git.example.com"
|
||||
assert port is None
|
||||
|
||||
|
||||
def test_assert_remote_host_allowed_refuses_a_name_that_resolves_private():
|
||||
"""A public-looking name can resolve into the private network."""
|
||||
|
||||
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
|
||||
return [(2, 1, 6, "", ("10.1.2.3", port))]
|
||||
|
||||
with pytest.raises(RuleViolation):
|
||||
assert_remote_host_allowed("https://internal.example.com/repo.git", resolver=resolver)
|
||||
|
||||
|
||||
def test_assert_remote_host_allowed_can_be_told_to_allow_private():
|
||||
host, _ = assert_remote_host_allowed(
|
||||
"http://127.0.0.1:8080/repo.git", allow_private=True
|
||||
)
|
||||
assert host == "127.0.0.1"
|
||||
|
||||
|
||||
def test_assert_remote_host_allowed_accepts_scp_style_ssh():
|
||||
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
|
||||
return [(2, 1, 6, "", ("93.184.216.34", port))]
|
||||
|
||||
host, _ = assert_remote_host_allowed("git@git.example.com:team/app.git", resolver=resolver)
|
||||
assert host == "git.example.com"
|
||||
|
||||
|
||||
async def test_test_remote_refuses_a_private_host(tmp_path):
|
||||
adapter = _adapter(tmp_path, "https://example.com/x.git")
|
||||
from app.application.ports.repo_workspace import RemoteCredentials
|
||||
|
||||
check = await adapter.test_remote(
|
||||
url="http://169.254.169.254/repo.git", credentials=RemoteCredentials()
|
||||
)
|
||||
assert check.ok is False
|
||||
assert "داخلی" in check.message
|
||||
|
||||
|
||||
async def test_test_remote_lists_branches_of_a_local_repo(tmp_path, monkeypatch):
|
||||
"""The ls-remote path, with the SSRF check stubbed out.
|
||||
|
||||
The check itself is covered by the tests above; here the subject is the
|
||||
parsing of `git ls-remote --heads` and the branch list it produces. A local
|
||||
bare repo is used as the remote because the suite must not need a network.
|
||||
"""
|
||||
import app.infrastructure.adapters.git_workspace as module
|
||||
from app.application.ports.repo_workspace import RemoteCredentials
|
||||
|
||||
monkeypatch.setattr(module, "assert_remote_host_allowed", lambda *a, **k: ("local", None))
|
||||
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
|
||||
check = await adapter.test_remote(
|
||||
url=f"file://{remote}", credentials=RemoteCredentials()
|
||||
)
|
||||
|
||||
assert check.ok is True, check.message
|
||||
assert check.branches == ["main"]
|
||||
assert check.default_branch == "main"
|
||||
|
||||
|
||||
async def test_test_remote_reports_a_missing_repository(tmp_path, monkeypatch):
|
||||
import app.infrastructure.adapters.git_workspace as module
|
||||
from app.application.ports.repo_workspace import RemoteCredentials
|
||||
|
||||
monkeypatch.setattr(module, "assert_remote_host_allowed", lambda *a, **k: ("local", None))
|
||||
|
||||
adapter = _adapter(tmp_path, "https://example.com/x.git")
|
||||
check = await adapter.test_remote(
|
||||
url=f"file://{tmp_path}/does-not-exist.git", credentials=RemoteCredentials()
|
||||
)
|
||||
|
||||
assert check.ok is False
|
||||
assert check.message
|
||||
Reference in New Issue
Block a user