Skip to content

Commit d798bd0

Browse files
committed
Merge branch 'main' into svn-ssh-non-onteractive
2 parents f04d773 + fdf22cc commit d798bd0

20 files changed

Lines changed: 329 additions & 47 deletions

CHANGELOG.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
Release 0.14.0 (unreleased)
22
===========================
33

4+
* Warn when a project URL uses a plaintext transport scheme (#1229)
45
* Documentation and threat-model clarifications for existing release attestation support (#1208)
56
* Report SVN externals fetched during update (#1220)
67
* Use ``.cdx.json`` as the default extension for CycloneDX SBOM reports (#1118)
@@ -19,7 +20,8 @@ Release 0.14.0 (unreleased)
1920
* Fix arbitrary file write via malicious tar/zip symlink (#1152)
2021
* Prevent SSH command injection (#1152)
2122
* Allow manifests with no ``projects`` key so ``dfetch add`` can bootstrap empty manifest (#1197)
22-
* Run ``svn+ssh://`` connections in non-interactive mode to prevent hanging (#0)
23+
* Run ``svn+ssh://`` connections in non-interactive mode to prevent hanging (#1230)
24+
* SSH host key verification failures now display clearer error messages (#1230)
2325

2426
Release 0.13.0 (released 2026-03-30)
2527
====================================

dfetch/manifest/project.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,13 +321,38 @@
321321
import copy
322322
from collections.abc import Sequence
323323
from dataclasses import dataclass, field
324+
from urllib.parse import urlsplit, urlunsplit
324325

325326
from typing_extensions import Required, TypedDict
326327

328+
from dfetch.log import get_logger
327329
from dfetch.manifest.remote import Remote
328330
from dfetch.manifest.version import Version
329331
from dfetch.util.util import always_str_list, str_if_possible
330332

333+
logger = get_logger(__name__)
334+
335+
_PLAINTEXT_SCHEMES = frozenset({"http", "git", "svn"})
336+
337+
338+
def plaintext_warning(url: str) -> str:
339+
"""Return a warning string if *url* uses a plaintext transport, else empty string."""
340+
parsed = urlsplit(url)
341+
scheme = parsed.scheme.lower()
342+
if scheme not in _PLAINTEXT_SCHEMES:
343+
return ""
344+
host = parsed.hostname or ""
345+
try:
346+
port = parsed.port
347+
except ValueError:
348+
port = None
349+
netloc = f"{host}:{port}" if isinstance(port, int) else host
350+
redacted_url = urlunsplit((scheme, netloc, parsed.path, "", ""))
351+
return (
352+
f"Project URL '{redacted_url}' uses plaintext transport ({scheme}://). "
353+
"Use https:// or SSH (e.g. svn+ssh://) to prevent interception."
354+
)
355+
331356

332357
@dataclass
333358
class Integrity:

dfetch/project/subproject.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from collections.abc import Callable, Sequence
77

88
from dfetch.log import get_logger
9-
from dfetch.manifest.project import ProjectEntry
9+
from dfetch.manifest.project import ProjectEntry, plaintext_warning
1010
from dfetch.manifest.version import Version
1111
from dfetch.project.abstract_check_reporter import AbstractCheckReporter
1212
from dfetch.project.metadata import Dependency, InvalidMetadataError, Metadata
@@ -129,6 +129,8 @@ def update(
129129
f"Fetching {to_fetch}",
130130
enabled=self._show_animations,
131131
):
132+
if warning := plaintext_warning(self.__project.remote_url):
133+
logger.print_warning_line(self.__project.name, warning)
132134
actually_fetched, dependency = self._fetch_impl(to_fetch)
133135
self._log_project(f"Fetched {actually_fetched}")
134136

@@ -213,6 +215,8 @@ def check_for_update(
213215
with logger.status(
214216
self.__project.name, "Checking", enabled=self._show_animations
215217
):
218+
if warning := plaintext_warning(self.__project.remote_url):
219+
logger.print_warning_line(self.__project.name, warning)
216220
latest_version = self._check_for_newer_version()
217221

218222
if not latest_version:

dfetch/project/svnsubproject.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dfetch.manifest.version import Version
1010
from dfetch.project.metadata import Dependency
1111
from dfetch.project.subproject import SubProject
12+
from dfetch.util.cmdline import SubprocessCommandError
1213
from dfetch.util.license import is_license_file
1314
from dfetch.util.util import (
1415
find_matching_files,
@@ -159,8 +160,16 @@ def _fetch_impl(self, version: Version) -> tuple[Version, list[Dependency]]:
159160

160161
def _fetch_externals(self, complete_path: str, revision: str) -> list[Dependency]:
161162
"""Detect and log SVN externals that were exported with the project."""
163+
try:
164+
externals = SvnRepo.externals_from_url(complete_path, revision)
165+
except SubprocessCommandError:
166+
logger.warning(
167+
"Could not retrieve SVN externals from '%s' — skipping report.",
168+
complete_path,
169+
)
170+
return []
162171
vcs_deps = []
163-
for external in SvnRepo.externals_from_url(complete_path, revision):
172+
for external in externals:
164173
path_display = "./" + external.path.lstrip("./")
165174
display_branch = external.branch or SvnRepo.DEFAULT_BRANCH
166175
self._log_project(

dfetch/util/cmdline.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,22 @@ def run_on_cmdline(
4040
logger: logging.Logger,
4141
cmd: list[str],
4242
env: Mapping[str, str] | None = None,
43+
timeout: float | None = None,
4344
) -> "subprocess.CompletedProcess[Any]":
4445
"""Run a command and log the output, and raise if something goes wrong."""
4546
logger.debug(f"Running {cmd}")
4647

4748
try:
48-
proc = subprocess.run(cmd, env=env, capture_output=True, check=True) # nosec
49+
proc = subprocess.run(
50+
cmd, env=env, capture_output=True, check=True, timeout=timeout
51+
) # nosec
52+
except subprocess.TimeoutExpired as exc:
53+
raise SubprocessCommandError(
54+
list(exc.cmd) if isinstance(exc.cmd, (list, tuple)) else [str(exc.cmd)],
55+
"",
56+
f"Command timed out after {exc.timeout:.0f}s",
57+
124,
58+
) from exc
4959
except subprocess.CalledProcessError as exc:
5060
raise SubprocessCommandError(
5161
exc.cmd,

dfetch/vcs/svn.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
logger = get_logger(__name__)
1919

2020
_SSH_HOST_KEY_MSGS = ("host key verification failed", "authenticity of host")
21+
_EXTERNALS_QUERY_TIMEOUT = 30
2122

2223

2324
# As a cli tool, we can safely assume this remains stable during the runtime, caching for speed is better
@@ -45,11 +46,13 @@ def _raise_if_ssh_host_key_error(url: str, exc: SubprocessCommandError) -> None:
4546
"""Raise a helpful RuntimeError if *exc* looks like an SSH host-key failure."""
4647
stderr_lower = exc.stderr.lower()
4748
if any(msg in stderr_lower for msg in _SSH_HOST_KEY_MSGS):
49+
parsed = urlparse(url)
50+
host_only = parsed.hostname or url
4851
target = _ssh_target_from_url(url)
4952
raise RuntimeError(
5053
f"SSH host key verification failed while connecting to '{url}'.\n"
5154
"Add the host to your known hosts file, for example by running:\n"
52-
f" ssh-keyscan {target} >> ~/.ssh/known_hosts\n"
55+
f" ssh-keyscan {host_only} >> ~/.ssh/known_hosts\n"
5356
"Or test the SSH connection manually:\n"
5457
f" ssh -T {target}"
5558
) from exc
@@ -134,7 +137,7 @@ def list_of_tags(self) -> list[str]:
134137
)
135138
except SubprocessCommandError as exc:
136139
_raise_if_ssh_host_key_error(self._remote, exc)
137-
raise
140+
return []
138141
return [
139142
str(tag).strip("/\r") for tag in result.stdout.decode().split("\n") if tag
140143
]
@@ -205,7 +208,11 @@ def is_svn(self) -> bool:
205208
"""Check if is SVN."""
206209
try:
207210
with in_directory(self._path):
208-
run_on_cmdline(logger, ["svn", "info", "--non-interactive"])
211+
run_on_cmdline(
212+
logger,
213+
["svn", "info", "--non-interactive"],
214+
env=_extend_env_for_non_interactive_mode(),
215+
)
209216
return True
210217
except (SubprocessCommandError, RuntimeError):
211218
return False
@@ -222,6 +229,7 @@ def externals(self) -> list[External]:
222229
"svn:externals",
223230
"-R",
224231
],
232+
env=_extend_env_for_non_interactive_mode(),
225233
)
226234
repo_root = SvnRepo.get_info_from_target()["Repository Root"]
227235
return SvnRepo._parse_externals(
@@ -231,11 +239,23 @@ def externals(self) -> list[External]:
231239
@staticmethod
232240
def externals_from_url(url: str, revision: str = "") -> list[External]:
233241
"""Get list of externals from a remote SVN URL."""
234-
cmd = ["svn", "--non-interactive", "propget", "svn:externals", "-R"]
242+
cmd = [
243+
"svn",
244+
"--non-interactive",
245+
"propget",
246+
"svn:externals",
247+
"--depth",
248+
"immediates",
249+
]
235250
if revision:
236251
cmd += ["--revision", revision]
237252
cmd += [url]
238-
result = run_on_cmdline(logger, cmd, env=_extend_env_for_non_interactive_mode())
253+
result = run_on_cmdline(
254+
logger,
255+
cmd,
256+
env=_extend_env_for_non_interactive_mode(),
257+
timeout=_EXTERNALS_QUERY_TIMEOUT,
258+
)
239259
repo_root = SvnRepo.get_info_from_target(url)["Repository Root"]
240260
normalized = SvnRepo._normalize_url_prefix(result.stdout.decode(), url)
241261
return SvnRepo._parse_externals(normalized, repo_root)
@@ -518,7 +538,9 @@ def create_diff(
518538
)
519539

520540
with in_directory(self._path):
521-
patch_text = run_on_cmdline(logger, cmd).stdout
541+
patch_text = run_on_cmdline(
542+
logger, cmd, env=_extend_env_for_non_interactive_mode()
543+
).stdout
522544

523545
if not patch_text.strip():
524546
return Patch.empty().convert_type(PatchType.SVN)
@@ -537,6 +559,7 @@ def get_username(self) -> str:
537559
"author",
538560
self._path,
539561
],
562+
env=_extend_env_for_non_interactive_mode(),
540563
)
541564
return str(result.stdout.decode().strip())
542565
except SubprocessCommandError:

doc/explanation/security.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ to reproduce a deterministic dependency state.
9696
allow exfiltration risks if upstream sources are compromised or intentionally
9797
malicious.
9898

99+
.. note::
100+
101+
dfetch warns during dependency update/check operations when a project URL uses a plaintext
102+
transport scheme (``http://``, ``git://``, or ``svn://``). Use ``https://``
103+
or SSH (e.g. ``svn+ssh://``) to protect dependency fetches against interception.
104+
99105
Threat Models
100106
-------------
101107

doc/explanation/threat_model_usage.rst

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ Asset Identification
721721
- Process
722722
- High / High / High
723723
* - A-26: SVN Export (svn export)
724-
- Runs ``svn export --non-interactive --force`` to check out SVN dependencies. The ``--ignore-externals`` flag is NOT passed. SVN repositories with ``svn:externals`` properties will trigger additional fetches from third-party SVN servers not declared in ``dfetch.yaml``. After export, ``SvnSubProject._fetch_externals()`` queries the externals list and records each one as a ``Dependency`` with ``source_type='svn-external'`` — mirroring the metadata tracking that git submodules receive. These fetches bypass dfetch's manifest controls: no integrity hash and no code review of the external URL (the URL comes from the upstream repository, not from ``dfetch.yaml``).
724+
- Runs ``svn export --non-interactive --force`` to check out SVN dependencies. For ``svn+ssh://`` URLs the ``SVN_SSH`` environment variable is extended with ``-o BatchMode=yes`` so the SSH client does not prompt for a host-key confirmation (``_extend_env_for_non_interactive_mode()``, ``dfetch/vcs/svn.py``). The ``--ignore-externals`` flag is NOT passed. SVN repositories with ``svn:externals`` properties will trigger additional fetches from third-party SVN servers not declared in ``dfetch.yaml``. After export, ``SvnSubProject._fetch_externals()`` queries the externals list and records each one as a ``Dependency`` with ``source_type='svn-external'`` — mirroring the metadata tracking that git submodules receive. These fetches bypass dfetch's manifest controls: no integrity hash and no code review of the external URL (the URL comes from the upstream repository, not from ``dfetch.yaml``).
725725
- Process
726726
- High / High / High
727727
* - A-27: Git Clone (git init / fetch / checkout)
@@ -1620,8 +1620,8 @@ Threats
16201620
- | **Sev:** 🟠H
16211621
| **Risk:** 🟠H
16221622
| **STRIDE:** T I
1623-
| **Status:** Accept
1624-
- dfetch accepts ``http://``, ``svn://``, and other non-TLS scheme URLs; HTTPS enforcement is the manifest author's responsibility. Accepted based on the **No HTTPS enforcement** assumption: HTTPS enforcement is the responsibility of the manifest author; dfetch accepts non-TLS scheme URLs as written and does not upgrade or reject them.
1623+
| **Status:** Mitigate
1624+
- C-009 emits a visible warning immediately before the VCS command when a plaintext scheme (``http://``, ``git://``, ``svn://``) is detected, with credentials redacted and ``https://`` / ``svn+ssh://`` recommended. Detection only — dfetch does not reject or upgrade plaintext URLs; scheme selection remains the manifest author's responsibility.
16251625
* - DFT-03
16261626
- Path traversal in archive or patch extraction
16271627
- A-26: SVN Export (svn export)
@@ -1660,8 +1660,8 @@ Threats
16601660
- | **Sev:** 🟠H
16611661
| **Risk:** 🟠H
16621662
| **STRIDE:** T I
1663-
| **Status:** Accept
1664-
- dfetch accepts ``http://``, ``svn://``, and other non-TLS scheme URLs; HTTPS enforcement is the manifest author's responsibility. Accepted based on the **No HTTPS enforcement** assumption: HTTPS enforcement is the responsibility of the manifest author; dfetch accepts non-TLS scheme URLs as written and does not upgrade or reject them.
1663+
| **Status:** Mitigate
1664+
- C-009 emits a visible warning immediately before the VCS command when a plaintext scheme (``http://``, ``git://``, ``svn://``) is detected, with credentials redacted and ``https://`` / ``svn+ssh://`` recommended. Detection only — dfetch does not reject or upgrade plaintext URLs; scheme selection remains the manifest author's responsibility.
16651665

16661666

16671667
Controls
@@ -1699,7 +1699,7 @@ Controls
16991699
* - C-006
17001700
- Non-interactive VCS
17011701
- DFT-06
1702-
- ``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; ``--non-interactive`` for SVN. Credential prompts are suppressed to prevent interactive hijacking in CI. ``dfetch/vcs/git.py, dfetch/vcs/svn.py``
1702+
- ``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; ``--non-interactive`` for SVN. For ``svn+ssh://`` connections, the ``SVN_SSH`` environment variable is extended with ``-o BatchMode=yes`` (via ``_extend_env_for_non_interactive_mode()`` in ``dfetch/vcs/svn.py``) to prevent the SSH client from prompting for a host-key confirmation and hanging the process. Credential prompts are suppressed to prevent interactive hijacking in CI. ``dfetch/vcs/git.py, dfetch/vcs/svn.py``
17031703
* - C-007
17041704
- Subprocess safety
17051705
- DFT-06
@@ -1708,6 +1708,10 @@ Controls
17081708
- Manifest input validation
17091709
- DFT-04, DFT-08
17101710
- StrictYAML schema with ``SAFE_STR = Regex(r"^[^\x00-\x1F\x7F-\x9F]*$")`` rejects control characters in all string fields. ``dfetch/manifest/schema.py``
1711+
* - C-009
1712+
- Plaintext transport detection
1713+
- DFT-26
1714+
- ``plaintext_warning()`` (``dfetch/manifest/project.py``) inspects the resolved remote URL immediately before each VCS command is issued (inside the ``check_for_update`` and ``update`` spinners in ``subproject.py``). If the scheme is ``http://``, ``git://``, or ``svn://``, a visible warning is emitted naming the redacted URL (credentials stripped from the userinfo component) and recommending ``https://`` or ``svn+ssh://``. Detection only — dfetch still proceeds with the plaintext connection; the control raises user awareness but does not enforce scheme selection. ``dfetch/manifest/project.py, dfetch/project/subproject.py``
17111715
* - C-034
17121716
- Hash algorithm allowlist (SHA-256/384/512 only)
17131717
- DFT-30

example/dfetch.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ manifest:
77
default: true # Set it as default
88

99
- name: sourceforge
10-
url-base: svn+ssh://svn.code.sf.net/p/
10+
url-base: svn://svn.code.sf.net/p/
1111

1212
projects:
1313

features/check-svn-repo.feature

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Feature: Checking dependencies from a svn repository
1111
1212
remotes:
1313
- name: cunit
14-
url-base: svn://svn.code.sf.net/p/cunit/code
14+
url-base: https://svn.code.sf.net/p/cunit/code
1515
1616
projects:
1717
- name: cunit-svn-rev-only
@@ -44,7 +44,7 @@ Feature: Checking dependencies from a svn repository
4444
4545
remotes:
4646
- name: cutter
47-
url-base: svn://svn.code.sf.net/p/cutter/svn/cutter
47+
url-base: https://svn.code.sf.net/p/cutter/svn/cutter
4848
4949
projects:
5050
- name: cutter-svn-tag
@@ -69,7 +69,7 @@ Feature: Checking dependencies from a svn repository
6969
7070
remotes:
7171
- name: cunit
72-
url-base: svn://svn.code.sf.net/p/cunit/code
72+
url-base: https://svn.code.sf.net/p/cunit/code
7373
default: true
7474
7575
projects:
@@ -152,7 +152,7 @@ Feature: Checking dependencies from a svn repository
152152
153153
remotes:
154154
- name: cutter
155-
url-base: svn://svn.code.sf.net/p/cutter/svn/cutter
155+
url-base: https://svn.code.sf.net/p/cutter/svn/cutter
156156
157157
projects:
158158
- name: cutter-svn-tag

0 commit comments

Comments
 (0)