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:
mjalmoudy94
2026-10-14 00:18:13 +03:30
parent a0c8473eb8
commit d41e00ad10
10 changed files with 2242 additions and 6 deletions
+100 -3
View File
@@ -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:
+5
View File
@@ -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
+152 -3
View File
@@ -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
+17
View File
@@ -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."""