fix(security): close INFO ASVS findings — secret echo, TLS floor, mandatory tarball SHA, CORS/Content-Type guards, BUG-17

- V7.1.3: env known-insecure-default error no longer echoes the rejected secret value.
- V9.1.4: syslog-over-TLS forwarder + listener pin minimum_version=TLSv1_2.
- V12.1.2: updater tarball SHA-256 verification is now mandatory and fail-closed —
  /update and /update-self reject a missing digest (400), the executor rejects
  missing/mismatched digests before extract/apply. Every push path supplies it.
- V13.1.4: reject a wildcard '*' in DECNET_CORS_ORIGINS at startup.
- V13.1.5: enforce application/json on JSON write endpoints (415 otherwise),
  exempting multipart upload routes.
- BUG-17: SSE error log records the user uuid, not the resume cursor.

Also completes V2.1.7 consistently: the attacker-injectable PYTEST* env bypass is
replaced with explicit DECNET_TESTING=1 in the three remaining sites
(env.validate_public_binding, config logging, mysql url builder).

Tests added for every fix; unanimous adversarial review (no update-outage risk —
all push paths verified to send the digest).
This commit is contained in:
2026-06-10 13:50:06 -04:00
parent 245975a6dd
commit 337520c7ad
17 changed files with 520 additions and 73 deletions

View File

@@ -54,10 +54,13 @@ def test_health_returns_role_and_releases(client: TestClient, monkeypatch: pytes
def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
ex, "run_update",
lambda data, sha, install_dir, agent_dir, expected_sha256=None: {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"},
)
seen: dict[str, object] = {}
def _run_update(data, sha, expected_sha256, install_dir, agent_dir):
seen["expected_sha256"] = expected_sha256
return {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"}
monkeypatch.setattr(ex, "run_update", _run_update)
r = client.post(
"/update",
files={"tarball": ("tree.tgz", _tarball(), "application/gzip")},
@@ -65,6 +68,8 @@ def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch)
)
assert r.status_code == 200, r.text
assert r.json()["release"]["sha"] == "ABC123"
# Route forwards the digest verbatim — executor verifies it before extract.
assert seen["expected_sha256"] == "0" * 64
def test_update_rollback_returns_409(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -104,10 +109,13 @@ def test_update_self_requires_confirm(client: TestClient) -> None:
def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
ex, "run_update_self",
lambda data, sha, updater_install_dir, expected_sha256=None: {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]},
)
seen: dict[str, object] = {}
def _run_update_self(data, sha, updater_install_dir, expected_sha256):
seen["expected_sha256"] = expected_sha256
return {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]}
monkeypatch.setattr(ex, "run_update_self", _run_update_self)
r = client.post(
"/update-self",
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
@@ -115,6 +123,7 @@ def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPa
)
assert r.status_code == 200
assert r.json()["status"] == "self_update_queued"
assert seen["expected_sha256"] == "0" * 64
def test_rollback_happy(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -158,6 +167,47 @@ def test_update_without_sha256_is_rejected(client: TestClient) -> None:
assert "sha256" in r.json()["detail"]
def test_update_with_empty_sha256_is_rejected(client: TestClient) -> None:
# An explicit empty form value is treated the same as absent → 400.
r = client.post(
"/update",
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
data={"sha": "ABC", "sha256": ""},
)
assert r.status_code == 400
assert "sha256" in r.json()["detail"]
def test_update_self_without_sha256_is_rejected(client: TestClient) -> None:
r = client.post(
"/update-self",
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
data={"confirm_self": "true"},
)
assert r.status_code == 400
assert "sha256" in r.json()["detail"]
def test_update_mismatched_sha256_is_rejected_before_apply(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End-to-end through the REAL executor verify: a non-matching digest is a
500 UpdateError and no extraction/pip happens (extract/_run_pip would be
reached only AFTER the digest check, so we assert they are never called)."""
called: list[str] = []
monkeypatch.setattr(ex, "extract_tarball", lambda *a, **k: called.append("extract"))
monkeypatch.setattr(ex, "_run_pip", lambda *a, **k: called.append("pip"))
r = client.post(
"/update",
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
data={"sha": "ABC", "sha256": "0" * 64}, # wrong digest for this tarball
)
assert r.status_code == 500, r.text
assert "mismatch" in r.json()["detail"]["error"]
assert called == [] # rejected before any extract/install
# ------------------------- master-cert gate ---------------------------------

View File

@@ -8,6 +8,7 @@ against a ``tmp_path`` install dir.
"""
from __future__ import annotations
import hashlib
import io
import pathlib
import subprocess
@@ -32,6 +33,11 @@ def _make_tarball(files: dict[str, str]) -> bytes:
return buf.getvalue()
def _digest(tarball: bytes) -> str:
"""SHA-256 hex of the tarball — now mandatory on run_update/run_update_self."""
return hashlib.sha256(tarball).hexdigest()
class _PipOK:
returncode = 0
stdout = ""
@@ -207,6 +213,40 @@ def test_run_update_rejects_malformed_sha256(
)
@pytest.mark.parametrize("missing", ["", " ", None])
def test_run_update_rejects_missing_sha256_fail_closed(
install_dir: pathlib.Path, agent_dir: pathlib.Path, missing: Any,
) -> None:
"""V12.1.2 fail-closed: an absent/empty digest is rejected BEFORE any
extraction or pip-install. No staging tree is produced."""
tb = _make_tarball({"x.txt": "y"})
with pytest.raises(ex.UpdateError, match="required but was missing or empty"):
ex.run_update(
tb, sha="S", expected_sha256=missing, # type: ignore[arg-type]
install_dir=install_dir, agent_dir=agent_dir,
)
assert not (install_dir / "releases" / "active.new").exists()
@pytest.mark.parametrize("missing", ["", " ", None])
def test_run_update_self_rejects_missing_sha256_fail_closed(
install_dir: pathlib.Path, missing: Any,
) -> None:
active = install_dir / "releases" / "active"
active.mkdir()
(active / "marker").write_text("old-updater")
tb = _make_tarball({"marker": "new-updater"})
with pytest.raises(ex.UpdateError, match="required but was missing or empty"):
ex.run_update_self(
tb, sha="U", updater_install_dir=install_dir,
expected_sha256=missing, # type: ignore[arg-type]
exec_cb=lambda a: None,
)
# Active untouched, nothing staged.
assert (install_dir / "releases" / "active" / "marker").read_text() == "old-updater"
assert not (install_dir / "releases" / "active.new").exists()
def test_clean_stale_staging(install_dir: pathlib.Path) -> None:
staging = install_dir / "releases" / "active.new"
staging.mkdir()
@@ -229,7 +269,7 @@ def test_update_rotates_and_probes(
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
result = ex.run_update(tb, sha="NEWSHA", install_dir=install_dir, agent_dir=agent_dir)
result = ex.run_update(tb, sha="NEWSHA", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
assert result["status"] == "updated"
assert result["release"]["sha"] == "NEWSHA"
@@ -252,7 +292,7 @@ def test_update_first_install_without_previous(
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "first"})
result = ex.run_update(tb, sha="S1", install_dir=install_dir, agent_dir=agent_dir)
result = ex.run_update(tb, sha="S1", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
assert result["status"] == "updated"
assert not (install_dir / "releases" / "prev").exists()
@@ -273,7 +313,7 @@ def test_update_pip_failure_aborts_before_rotation(
tb = _make_tarball({"marker.txt": "new"})
with pytest.raises(ex.UpdateError, match="pip install failed") as ei:
ex.run_update(tb, sha="S", install_dir=install_dir, agent_dir=agent_dir)
ex.run_update(tb, sha="S", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
assert "resolver error" in ei.value.stderr
# Nothing rotated — old active still live, no prev created.
@@ -309,7 +349,7 @@ def test_update_probe_failure_rolls_back(
tb = _make_tarball({"marker.txt": "new"})
with pytest.raises(ex.UpdateError, match="health probe") as ei:
ex.run_update(tb, sha="NEWSHA", install_dir=install_dir, agent_dir=agent_dir)
ex.run_update(tb, sha="NEWSHA", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
assert ei.value.rolled_back is True
assert "connection refused" in ei.value.stderr
@@ -381,6 +421,7 @@ def test_update_self_rotates_and_calls_exec_cb(
tb = _make_tarball({"marker": "new-updater"})
result = ex.run_update_self(
tb, sha="USHA", updater_install_dir=install_dir,
expected_sha256=_digest(tb),
exec_cb=lambda argv: seen_argv.append(argv),
)
assert result["status"] == "self_update_queued"
@@ -412,7 +453,7 @@ def test_update_self_under_systemd_defers_to_systemctl(
monkeypatch.setattr(ex.os, "execv", lambda *a, **k: pytest.fail("execv taken under systemd"))
tb = _make_tarball({"marker": "new-updater"})
result = ex.run_update_self(tb, sha="USHA", updater_install_dir=install_dir)
result = ex.run_update_self(tb, sha="USHA", updater_install_dir=install_dir, expected_sha256=_digest(tb))
assert result == {"status": "self_update_queued", "via": "systemd"}
assert len(popen_calls) == 1
sh_cmd = popen_calls[0]
@@ -431,7 +472,7 @@ def test_update_self_pip_failure_leaves_active_intact(
tb = _make_tarball({"marker": "new-updater"})
with pytest.raises(ex.UpdateError, match="pip install failed"):
ex.run_update_self(tb, sha="U", updater_install_dir=install_dir, exec_cb=lambda a: None)
ex.run_update_self(tb, sha="U", updater_install_dir=install_dir, expected_sha256=_digest(tb), exec_cb=lambda a: None)
assert (install_dir / "releases" / "active" / "marker").read_text() == "old-updater"
assert not (install_dir / "releases" / "active.new").exists()