fix(release): support validated per-run SSH address family
This commit is contained in:
@@ -58,6 +58,45 @@ checkouts remain usable for read-only planning, but every durable executor
|
||||
fails closed there; clone the registered origins into a private workspace
|
||||
before releasing.
|
||||
|
||||
For a host with a confirmed IPv6 connection timeout, set
|
||||
`GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet` only for the release-tool invocation.
|
||||
When unset, the original SSH command is preserved, including the trusted
|
||||
operator's per-host `AddressFamily` configuration (normally `any`). Explicit
|
||||
values accepted by the shared source/tag Git helper are exactly `any`, `inet`
|
||||
(IPv4 only), and `inet6` (IPv6 only). Empty, misspelled, whitespace-padded, or
|
||||
injected values fail before Git starts. The selector only adds the corresponding
|
||||
fixed SSH `AddressFamily` option: it does not change DNS, host-key verification,
|
||||
the registered remote, authentication, `BatchMode=yes`, or `ConnectTimeout=8`.
|
||||
Arbitrary `GIT_SSH_COMMAND` overrides remain ignored. For example, start a
|
||||
single local console invocation with:
|
||||
|
||||
```sh
|
||||
GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet \
|
||||
./.venv/bin/python tools/release/release-console.py
|
||||
```
|
||||
|
||||
The same process-scoped setting applies to canonical source/tag readbacks and
|
||||
registry-candidate source verification. Under Flatpak, pass it explicitly to
|
||||
the host invocation with `flatpak-spawn --host /usr/bin/env
|
||||
GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet ...`. It is not a global SSH setting
|
||||
and does not affect the website publisher's separate transport sanitizer, npm,
|
||||
or HTTP downloads. An IPv4-only setting cannot reach IPv6-only hosts; omit it
|
||||
or use `any` when the diagnosed restriction no longer applies.
|
||||
|
||||
Deutsch: Bei einem bestätigten IPv6-Verbindungs-Timeout kann für genau einen
|
||||
Release-Werkzeugaufruf `GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet` gesetzt werden.
|
||||
Ohne diese Variable bleibt der bisherige SSH-Befehl einschließlich der
|
||||
vertrauenswürdigen Host-Konfiguration unverändert (normalerweise `any`).
|
||||
Explizit zulässig sind ausschließlich `any`, `inet` (nur IPv4) und `inet6`
|
||||
(nur IPv6). Andere oder leere Werte werden vor dem Git-Aufruf abgewiesen.
|
||||
DNS, Hostschlüsselprüfung, registrierte Quelladresse, Authentifizierung und
|
||||
Zeitlimit bleiben unverändert; frei vorgegebene SSH-Befehle bleiben gesperrt.
|
||||
Unter Flatpak die Variable ausdrücklich an den Host-Aufruf übergeben. Die
|
||||
Auswahl gilt für den gemeinsamen Git-Helfer der Quell-/Tag-Prüfungen, nicht
|
||||
für den separaten Website-Publisher, npm oder HTTP-Downloads. Sie ändert keine
|
||||
globale Konfiguration. Nach Behebung des Netzwerkproblems die Variable
|
||||
weglassen oder auf `any` setzen; IPv4-only erreicht keine IPv6-only-Ziele.
|
||||
|
||||
The runtime itself is part of the authority boundary. Durable run creation
|
||||
verifies the meta checkout, release/check tooling, repository registry, Python
|
||||
environment, loaded `govoplan_core` and cryptography packages, and Git/SSH
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
@@ -17,6 +18,80 @@ from govoplan_release import git_state # noqa: E402
|
||||
|
||||
|
||||
class ReleaseGitStateTests(unittest.TestCase):
|
||||
def test_unset_ssh_address_family_preserves_original_command_and_operator_config(self) -> None:
|
||||
environment = git_state.sanitized_git_environment({})
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
|
||||
],
|
||||
shlex.split(environment["GIT_SSH_COMMAND"]),
|
||||
)
|
||||
self.assertNotIn("GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY", environment)
|
||||
self.assertEqual(environment, git_state.sanitized_git_environment(environment))
|
||||
|
||||
def test_ssh_address_family_accepts_only_fixed_choices_and_survives_resanitizing(self) -> None:
|
||||
for family in ("any", "inet", "inet6"):
|
||||
with self.subTest(family=family):
|
||||
environment = git_state.sanitized_git_environment({
|
||||
"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": family,
|
||||
"GIT_SSH_COMMAND": "/attacker/ssh -o StrictHostKeyChecking=no",
|
||||
"GIT_SSH": "/attacker/ssh",
|
||||
"PATH": "/attacker/bin",
|
||||
})
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
|
||||
"-o", f"AddressFamily={family}",
|
||||
],
|
||||
shlex.split(environment["GIT_SSH_COMMAND"]),
|
||||
)
|
||||
self.assertNotIn("GIT_SSH", environment)
|
||||
self.assertEqual("/usr/bin:/bin", environment["PATH"])
|
||||
self.assertEqual(environment, git_state.sanitized_git_environment(environment))
|
||||
|
||||
def test_invalid_ssh_address_family_is_rejected_before_git_runs(self) -> None:
|
||||
for invalid in (
|
||||
"", "INET", "ipv4", " inet", "inet ", "inet\n",
|
||||
"inet; touch /not-executed", "inet -o StrictHostKeyChecking=no",
|
||||
"$(not-executed)",
|
||||
):
|
||||
with (
|
||||
self.subTest(value=invalid),
|
||||
patch.dict("os.environ", {"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": invalid}),
|
||||
patch.object(git_state.subprocess, "run") as run,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY must be any, inet, or inet6",
|
||||
):
|
||||
git_state.git(Path("/workspace/govoplan-core"), "status", "--porcelain")
|
||||
run.assert_not_called()
|
||||
|
||||
def test_source_provenance_readback_keeps_family_but_discards_ssh_command_override(self) -> None:
|
||||
from govoplan_release.source_provenance import inspect_remote_tag
|
||||
|
||||
completed = subprocess.CompletedProcess(
|
||||
[], 0, f"{'a' * 40}\trefs/tags/v1.2.3\n{'b' * 40}\trefs/tags/v1.2.3^{{}}\n", "",
|
||||
)
|
||||
with (
|
||||
patch.dict("os.environ", {
|
||||
"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": "inet",
|
||||
"GIT_SSH_COMMAND": "/attacker/ssh -o StrictHostKeyChecking=no",
|
||||
}),
|
||||
patch("govoplan_release.repository_tag.subprocess.run", return_value=completed) as run,
|
||||
):
|
||||
result = inspect_remote_tag(
|
||||
path=Path("/workspace/govoplan-core"), remote="origin",
|
||||
remote_url="git@git.add-ideas.de:GovOPlaN/govoplan-core.git", tag="v1.2.3",
|
||||
)
|
||||
|
||||
self.assertEqual("b" * 40, result.commit)
|
||||
self.assertEqual(
|
||||
"/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8 -o AddressFamily=inet",
|
||||
run.call_args.kwargs["env"]["GIT_SSH_COMMAND"],
|
||||
)
|
||||
|
||||
def test_manifest_version_does_not_confuse_interface_versions(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
@@ -197,6 +197,13 @@ def sanitized_git_environment(
|
||||
"""Keep only deliberate process/auth inputs and neutralize Git redirection."""
|
||||
|
||||
environment = os.environ if source is None else source
|
||||
address_family = environment.get("GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY")
|
||||
if "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY" in environment and address_family not in (
|
||||
"any", "inet", "inet6",
|
||||
):
|
||||
raise ValueError(
|
||||
"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY must be any, inet, or inet6"
|
||||
)
|
||||
result = {
|
||||
key: environment[key]
|
||||
for key in (
|
||||
@@ -221,6 +228,10 @@ def sanitized_git_environment(
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
)
|
||||
if address_family is not None:
|
||||
result["GIT_SSH_COMMAND"] += f" -o AddressFamily={address_family}"
|
||||
# Preserve only an explicit, validated choice across re-sanitization.
|
||||
result["GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY"] = address_family
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user