diff --git a/DETAIL.md b/DETAIL.md index 9daa94a7..a9ee0034 100644 --- a/DETAIL.md +++ b/DETAIL.md @@ -605,7 +605,7 @@ Current important `.env` values include: Current `.bootstrap` values include: - `INSTALL_ID` -- optional `TELEMETRY=false` +- `TELEMETRY` (`false` by default; set `true` to opt in) ## CLI Command Reference @@ -1192,10 +1192,13 @@ Current identity model: - `.env` stores a rotating `TC_CONFIGURE_ID` Current transport behavior: -- events are sent to the configured HTTPS telemetry endpoint +- telemetry is **opt-in**: disabled unless `.bootstrap` contains `TELEMETRY=true` +- `bootstrap` prompts once on first run; `tcapsule set-telemetry --enable/--disable/--status` changes it later +- when enabled, events are sent to the configured HTTPS telemetry endpoint - started events are sent asynchronously - finished events are sent synchronously so they are not lost at process exit -- if `.bootstrap` contains `TELEMETRY=false`, telemetry is disabled +- payloads are scrubbed before send: LAN IPv4/IPv6 addresses, SSH/SMB host targets, and local `/Volumes`, `/mnt`, `/Users`, `/home`, `/private` paths are dropped or redacted +- `TCAPSULE_TELEMETRY_ANONYMOUS=1` replaces the stable `INSTALL_ID` with a per-run id and drops `TC_CONFIGURE_ID` ## Uninstall diff --git a/README.md b/README.md index ceb5fdc5..092c3099 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,9 @@ This should be treated as a LAN-only setup. Do not expose this SMB service direc Also note that the current auth model maps SMB access to `root` internally on the Time Capsule. That is a deliberate compatibility choice for this old firmware, as the version of NetBSD 6 running on the Time Capsule errors when Samba tries to switch users. -The commands have logging and telemetry enabled by default. Errors and exceptions are logged so they can be easily investigated later. +The commands log errors and exceptions locally so they can be investigated later. + +Anonymous usage telemetry is **off by default**. Nothing is sent to a remote endpoint unless you opt in during `bootstrap` or with `tcapsule set-telemetry --enable` (disable again with `--disable`). Even when enabled, payloads never include your device LAN IP, SSH/SMB host targets, local filesystem paths, or the device password. ## For Developers And Maintainers diff --git a/src/timecapsulesmb/cli/bootstrap.py b/src/timecapsulesmb/cli/bootstrap.py index 39de9d58..83d58ef7 100644 --- a/src/timecapsulesmb/cli/bootstrap.py +++ b/src/timecapsulesmb/cli/bootstrap.py @@ -9,9 +9,14 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.util import color_red -from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.identity import ( + default_bootstrap_path, + ensure_install_id, + load_install_identity, + set_telemetry_enabled, +) from timecapsulesmb.services.runtime import load_optional_env_config -from timecapsulesmb.telemetry import TelemetryClient +from timecapsulesmb.telemetry import DEFAULT_TELEMETRY_URL, TelemetryClient from timecapsulesmb.transport.local import find_command @@ -407,12 +412,47 @@ def install_required_host_tools() -> None: print(f"Installed required host tools: {_format_tools(missing_tools)}", flush=True) +TELEMETRY_DISCLOSURE = ( + "\nOptional anonymous usage telemetry\n" + " What: install id, CLI/Samba version, host OS, device model, command + result.\n" + f" Where: {DEFAULT_TELEMETRY_URL} (override with TCAPSULE_TELEMETRY_URL)\n" + " Default: OFF. Change any time with `tcapsule set-telemetry --enable/--disable`.\n" +) + + +def _prompt_telemetry_choice(no_input: bool) -> bool: + if no_input or not sys.stdin.isatty(): + return False + print(TELEMETRY_DISCLOSURE, flush=True) + try: + answer = input("Enable telemetry to help the project? [y/N]: ").strip().lower() + except EOFError: + return False + return answer in {"y", "yes"} + + +def _apply_first_run_telemetry_choice(no_input: bool) -> None: + try: + bootstrap_path = default_bootstrap_path() + first_run = load_install_identity(bootstrap_path).install_id is None + except Exception: + ensure_install_id() + return + if first_run: + # Prompt before writing .bootstrap so an interrupted first run can be + # retried and re-prompted instead of being silently left disabled. + set_telemetry_enabled(_prompt_telemetry_choice(no_input), bootstrap_path) + else: + ensure_install_id(bootstrap_path) + + def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Prepare the local host for TimeCapsuleSMB user workflows.") parser.add_argument("--python", default=sys.executable or "python3", help="Python interpreter to use for the repo .venv") + parser.add_argument("--no-input", action="store_true", help="Do not prompt; leave telemetry disabled") args = parser.parse_args(argv) - ensure_install_id() + _apply_first_run_telemetry_choice(args.no_input) config = load_optional_env_config() telemetry = TelemetryClient.from_config(config) with CommandContext( diff --git a/src/timecapsulesmb/cli/main.py b/src/timecapsulesmb/cli/main.py index 3aa1e5dc..a9fdc692 100644 --- a/src/timecapsulesmb/cli/main.py +++ b/src/timecapsulesmb/cli/main.py @@ -4,7 +4,7 @@ import sys from typing import Optional -from . import activate, api, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install +from . import activate, api, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, set_telemetry, repair_xattrs, uninstall, validate_install from timecapsulesmb.core.paths import DistributionRootError from timecapsulesmb.services.version_check import check_client_version, render_version_block_message @@ -21,6 +21,7 @@ "fsck": fsck.main, "paths": paths.main, "set-ssh": set_ssh.main, + "set-telemetry": set_telemetry.main, "repair-xattrs": repair_xattrs.main, "uninstall": uninstall.main, "validate-install": validate_install.main, diff --git a/src/timecapsulesmb/cli/set_telemetry.py b/src/timecapsulesmb/cli/set_telemetry.py new file mode 100644 index 00000000..6697bc67 --- /dev/null +++ b/src/timecapsulesmb/cli/set_telemetry.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import argparse +from typing import Optional + +from timecapsulesmb.identity import default_bootstrap_path, load_install_identity, set_telemetry_enabled + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Enable or disable anonymous usage telemetry.") + group = parser.add_mutually_exclusive_group() + group.add_argument("--enable", action="store_true", help="Enable telemetry") + group.add_argument("--disable", action="store_true", help="Disable telemetry") + group.add_argument("--status", action="store_true", help="Only report the current setting") + args = parser.parse_args(argv) + + path = default_bootstrap_path() + if args.status: + identity = load_install_identity(path) + print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}") + return 0 + + if not args.enable and not args.disable: + parser.error("select --enable, --disable, or --status") + + identity = set_telemetry_enabled(bool(args.enable), path) + print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}") + return 0 diff --git a/src/timecapsulesmb/core/redaction.py b/src/timecapsulesmb/core/redaction.py index b95f8a38..67f704e3 100644 --- a/src/timecapsulesmb/core/redaction.py +++ b/src/timecapsulesmb/core/redaction.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from collections.abc import Mapping from pathlib import Path @@ -7,6 +8,59 @@ SENSITIVE_KEY_PARTS = ("credentials", "password", "secret", "token") REDACTED = "" +TELEMETRY_DROP_KEYS = frozenset({ + "host", + "ssh_host", + "smb_host", + "mountpoint", + "fsck_mountpoint", + "root", + "path", + "backup_dir", +}) + +_SENSITIVE_PATH_PREFIXES = ("/Volumes", "/mnt", "/Users", "/home", "/private") +_IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") +_IPV6_RE = re.compile(r"\b(?:[0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F]{1,4}\b") +_ABS_PATH_RE = re.compile(r"(?:/Volumes|/mnt|/Users|/home|/private)(?:/[^\s\"']*)?") + + +def scrub_network_and_paths(text: str) -> str: + scrubbed = _ABS_PATH_RE.sub(REDACTED, text) + scrubbed = _IPV6_RE.sub(REDACTED, scrubbed) + scrubbed = _IPV4_RE.sub(REDACTED, scrubbed) + return scrubbed + + +def scrub_telemetry_value(value: object) -> object: + if isinstance(value, Mapping): + scrubbed: dict[str, object] = {} + for key, item in value.items(): + key_text = str(key) + if key_text.lower() in TELEMETRY_DROP_KEYS: + continue + scrubbed[key_text] = scrub_telemetry_value(item) + return scrubbed + if isinstance(value, (list, tuple, set)): + return [scrub_telemetry_value(item) for item in value] + if isinstance(value, Path): + path_str = str(value) + if path_str.startswith(_SENSITIVE_PATH_PREFIXES): + return REDACTED + return scrub_network_and_paths(path_str) + if isinstance(value, str): + return scrub_network_and_paths(value) + if value is None or isinstance(value, (bool, int, float)): + return value + return scrub_network_and_paths(str(value)) + + +def scrub_telemetry_mapping(mapping: Mapping[str, object]) -> dict[str, object]: + scrubbed = scrub_telemetry_value(dict(mapping)) + if isinstance(scrubbed, dict): + return scrubbed + return {} + def redact_sensitive_fields(value: object) -> object: if isinstance(value, Mapping): diff --git a/src/timecapsulesmb/identity.py b/src/timecapsulesmb/identity.py index 17966629..86539825 100644 --- a/src/timecapsulesmb/identity.py +++ b/src/timecapsulesmb/identity.py @@ -37,21 +37,25 @@ def parse_bootstrap_values(path: Path | None = None) -> dict[str, str]: return values +TELEMETRY_ENABLED_VALUES = frozenset({"true", "1", "yes", "on"}) + + def load_install_identity(path: Path | None = None) -> InstallIdentity: values = parse_bootstrap_values(path) telemetry_raw = values.get("TELEMETRY", "").strip().lower() - telemetry_enabled = telemetry_raw != "false" + telemetry_enabled = telemetry_raw in TELEMETRY_ENABLED_VALUES return InstallIdentity( install_id=values.get("INSTALL_ID") or None, telemetry_enabled=telemetry_enabled, ) -def render_bootstrap_text(install_id: str, *, telemetry_enabled: bool = True) -> str: - lines = [f"INSTALL_ID={install_id}"] - if not telemetry_enabled: - lines.append("TELEMETRY=false") - lines.append("") +def render_bootstrap_text(install_id: str, *, telemetry_enabled: bool = False) -> str: + lines = [ + f"INSTALL_ID={install_id}", + f"TELEMETRY={'true' if telemetry_enabled else 'false'}", + "", + ] return "\n".join(lines) diff --git a/src/timecapsulesmb/telemetry/__init__.py b/src/timecapsulesmb/telemetry/__init__.py index 23b2d0cd..9127ffd6 100644 --- a/src/timecapsulesmb/telemetry/__init__.py +++ b/src/timecapsulesmb/telemetry/__init__.py @@ -13,6 +13,7 @@ from pathlib import Path from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.core.redaction import scrub_telemetry_mapping from timecapsulesmb.core.release import CLI_VERSION, RELEASE_TAG, SAMBA_VERSION from timecapsulesmb.identity import load_install_identity @@ -21,6 +22,7 @@ DEFAULT_TELEMETRY_URL = "https://timecapsulesmb.jamesyc.com/v1/events" TELEMETRY_URL_ENV = "TCAPSULE_TELEMETRY_URL" TELEMETRY_TOKEN_ENV = "TCAPSULE_TELEMETRY_TOKEN" +TELEMETRY_ANONYMOUS_ENV = "TCAPSULE_TELEMETRY_ANONYMOUS" DEFAULT_TELEMETRY_TOKEN = "d65373762e893ae18c8aaa95a8f1b3a3464611f33b30983909543535fa8b0733" REQUEST_TIMEOUT_SECONDS = 10.0 MAX_SEND_ATTEMPTS = 2 @@ -61,14 +63,15 @@ def from_config( token = os.getenv(TELEMETRY_TOKEN_ENV, DEFAULT_TELEMETRY_TOKEN).strip() or None if not identity.install_id: return cls(endpoint=endpoint, token=token, context=None, enabled=False) + anonymous = _anonymous_mode_enabled() context = TelemetryContext( - install_id=identity.install_id, + install_id=str(uuid.uuid4()) if anonymous else identity.install_id, cli_version=CLI_VERSION, release_tag=RELEASE_TAG, samba_version=SAMBA_VERSION, host_os=detect_host_os(), host_os_version=detect_host_os_version(), - configure_id=config.get("TC_CONFIGURE_ID") or None, + configure_id=None if anonymous else (config.get("TC_CONFIGURE_ID") or None), device_model=None, device_syap=None, nbns_enabled=nbns_enabled, @@ -132,6 +135,7 @@ def emit( for key, value in fields.items(): if value is not None: payload[key] = value + payload = scrub_telemetry_mapping(payload) if synchronous: self._send_payload(payload) return @@ -171,6 +175,10 @@ def _send_payload(self, payload: dict[str, object]) -> None: return +def _anonymous_mode_enabled() -> bool: + return os.getenv(TELEMETRY_ANONYMOUS_ENV, "").strip().lower() in {"true", "1", "yes", "on"} + + def build_device_os_version(os_name: str | None, os_release: str | None, arch: str | None) -> str | None: if not os_name or not os_release or not arch: return None diff --git a/src/timecapsulesmb/telemetry/operation.py b/src/timecapsulesmb/telemetry/operation.py index 697fcf35..7b3938ea 100644 --- a/src/timecapsulesmb/telemetry/operation.py +++ b/src/timecapsulesmb/telemetry/operation.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Optional -from timecapsulesmb.core.redaction import SENSITIVE_KEY_PARTS +from timecapsulesmb.core.redaction import SENSITIVE_KEY_PARTS, scrub_telemetry_mapping from timecapsulesmb.telemetry import TelemetryClient @@ -171,7 +171,7 @@ def telemetry_options_from_params(params: Mapping[str, object]) -> dict[str, obj value = params.get(key) if value is not None: options[key] = _jsonable(value) - return options + return scrub_telemetry_mapping(options) def telemetry_options_from_args(args: object | None) -> dict[str, object]: @@ -192,7 +192,7 @@ def telemetry_details_from_payload( payload: object | None, ) -> dict[str, object]: extractor = DETAIL_EXTRACTORS.get(operation, _details_common) - return extractor(params, payload) + return scrub_telemetry_mapping(extractor(params, payload)) DetailExtractor = Callable[[Mapping[str, object], Optional[object]], dict[str, object]] diff --git a/tests/test_app_api.py b/tests/test_app_api.py index e11bded4..875d587d 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1177,7 +1177,7 @@ def run_fsck(params, context): self.assertEqual(finished.kwargs["risk"], "destructive") self.assertEqual(finished.kwargs["details"]["volume"], "Data") self.assertEqual(finished.kwargs["details"]["fsck_device"], "/dev/dk2") - self.assertEqual(finished.kwargs["details"]["fsck_mountpoint"], "/Volumes/Data") + self.assertNotIn("fsck_mountpoint", finished.kwargs["details"]) self.assertEqual(finished.kwargs["details"]["returncode"], 0) self.assertTrue(finished.kwargs["details"]["reboot_requested"]) self.assertTrue(finished.kwargs["details"]["waited"]) diff --git a/tests/test_identity.py b/tests/test_identity.py index 97581917..3eed23f1 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -23,7 +23,20 @@ def test_ensure_install_id_creates_bootstrap_file(self) -> None: self.assertTrue(install_id) values = parse_bootstrap_values(path) self.assertEqual(values["INSTALL_ID"], install_id) - self.assertNotIn("TELEMETRY", values) + self.assertEqual(values["TELEMETRY"], "false") + + def test_default_install_has_telemetry_disabled(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + ensure_install_id(path) + self.assertFalse(load_install_identity(path).telemetry_enabled) + + def test_telemetry_only_enabled_by_explicit_true(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + for raw, expected in (("true", True), ("1", True), ("yes", True), ("on", True), ("", False), ("nope", False)): + path = Path(tmp) / f".bootstrap-{raw or 'blank'}" + path.write_text(f"INSTALL_ID=install\nTELEMETRY={raw}\n") + self.assertEqual(load_install_identity(path).telemetry_enabled, expected) def test_ensure_install_id_preserves_telemetry_false(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -48,7 +61,7 @@ def test_set_telemetry_enabled_preserves_install_id_and_updates_preference(self) enabled = set_telemetry_enabled(True, path) self.assertEqual(enabled.install_id, "install-one") self.assertTrue(enabled.telemetry_enabled) - self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one"}) + self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one", "TELEMETRY": "true"}) if __name__ == "__main__": diff --git a/tests/test_set_telemetry.py b/tests/test_set_telemetry.py new file mode 100644 index 00000000..b0c37661 --- /dev/null +++ b/tests/test_set_telemetry.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.cli import bootstrap, set_telemetry +from timecapsulesmb.identity import load_install_identity + + +class TelemetryPromptTests(unittest.TestCase): + def test_prompt_returns_false_under_no_input(self) -> None: + self.assertFalse(bootstrap._prompt_telemetry_choice(no_input=True)) + + def test_prompt_returns_false_when_stdin_not_tty(self) -> None: + with mock.patch.object(sys.stdin, "isatty", return_value=False): + self.assertFalse(bootstrap._prompt_telemetry_choice(no_input=False)) + + def test_prompt_accepts_yes(self) -> None: + with mock.patch.object(sys.stdin, "isatty", return_value=True): + with mock.patch("builtins.input", return_value="y"): + self.assertTrue(bootstrap._prompt_telemetry_choice(no_input=False)) + + def test_first_run_leaves_telemetry_off_under_no_input(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + with mock.patch("timecapsulesmb.cli.bootstrap.default_bootstrap_path", return_value=path): + bootstrap._apply_first_run_telemetry_choice(no_input=True) + self.assertFalse(load_install_identity(path).telemetry_enabled) + self.assertIsNotNone(load_install_identity(path).install_id) + + def test_interrupt_during_first_run_prompt_does_not_persist(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + with mock.patch("timecapsulesmb.cli.bootstrap.default_bootstrap_path", return_value=path): + with mock.patch("timecapsulesmb.cli.bootstrap._prompt_telemetry_choice", side_effect=KeyboardInterrupt): + with self.assertRaises(KeyboardInterrupt): + bootstrap._apply_first_run_telemetry_choice(no_input=False) + # Nothing was written, so a later run still treats it as a first run. + self.assertFalse(path.exists()) + + +class SetTelemetryCommandTests(unittest.TestCase): + def _run(self, argv: list[str], path: Path) -> tuple[int, str]: + buffer = io.StringIO() + with mock.patch("timecapsulesmb.cli.set_telemetry.default_bootstrap_path", return_value=path): + with redirect_stdout(buffer): + rc = set_telemetry.main(argv) + return rc, buffer.getvalue() + + def test_enable_then_disable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + path.write_text("INSTALL_ID=install\n") + + rc, out = self._run(["--enable"], path) + self.assertEqual(rc, 0) + self.assertIn("enabled", out) + self.assertTrue(load_install_identity(path).telemetry_enabled) + + rc, out = self._run(["--disable"], path) + self.assertEqual(rc, 0) + self.assertIn("disabled", out) + self.assertFalse(load_install_identity(path).telemetry_enabled) + + def test_status_only(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + path.write_text("INSTALL_ID=install\nTELEMETRY=true\n") + rc, out = self._run(["--status"], path) + self.assertEqual(rc, 0) + self.assertIn("enabled", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index b7423bc3..0f89e436 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -45,7 +45,7 @@ class TelemetryTests(unittest.TestCase): def test_emit_builds_schema_v5_payload_without_stale_config_identity(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values( { @@ -74,7 +74,7 @@ def test_emit_builds_schema_v5_payload_without_stale_config_identity(self) -> No def test_from_config_can_exclude_stale_device_identity(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values( { @@ -103,7 +103,7 @@ def test_emit_is_disabled_when_bootstrap_has_telemetry_false(self) -> None: def test_send_payload_retries_once_on_transport_failure(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) success_response = mock.MagicMock() @@ -116,7 +116,7 @@ def test_send_payload_retries_once_on_transport_failure(self) -> None: def test_emit_does_not_raise_when_transport_has_unexpected_failure(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch("urllib.request.urlopen", side_effect=RuntimeError("unexpected transport failure")) as urlopen_mock: @@ -130,7 +130,7 @@ def __str__(self) -> str: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) success_response = mock.MagicMock() @@ -144,7 +144,7 @@ def __str__(self) -> str: def test_command_context_reuses_command_id_for_started_and_finished_events(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async") as dispatch_mock: @@ -189,7 +189,7 @@ def test_command_context_records_normalized_options_and_details(self) -> None: self.assertNotIn("password", started_kwargs["options"]) self.assertEqual(finished_kwargs["details"]["volume"], "Data") self.assertEqual(finished_kwargs["details"]["fsck_device"], "/dev/dk2") - self.assertEqual(finished_kwargs["details"]["fsck_mountpoint"], "/Volumes/Data") + self.assertNotIn("fsck_mountpoint", finished_kwargs["details"]) self.assertTrue(finished_kwargs["details"]["reboot_requested"]) self.assertFalse(finished_kwargs["details"]["verified"]) self.assertEqual(finished_kwargs["execution"]["version"], 1) @@ -387,7 +387,7 @@ def test_command_context_resolve_env_connection_requires_config(self) -> None: def test_command_context_marks_keyboard_interrupt_as_cancelled(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -405,7 +405,7 @@ def test_command_context_marks_keyboard_interrupt_as_cancelled(self) -> None: def test_command_context_captures_system_exit_error(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values( { @@ -428,14 +428,15 @@ def test_command_context_captures_system_exit_error(self) -> None: raise SystemExit("Connecting to the device failed, SSH error: bind [127.0.0.1]:108: Permission denied") finished_payload = send_mock.call_args.args[0] self.assertEqual(finished_payload["result"], "failure") - self.assertIn("Connecting to the device failed, SSH error: bind [127.0.0.1]:108: Permission denied", finished_payload["error"]) + self.assertIn("Connecting to the device failed, SSH error: bind []:108: Permission denied", finished_payload["error"]) self.assertIn("Debug context:", finished_payload["error"]) - self.assertIn("ssh_opts=-L 108:127.0.0.1:108", finished_payload["error"]) + self.assertIn("ssh_opts=-L 108::108", finished_payload["error"]) + self.assertNotIn("127.0.0.1", finished_payload["error"]) def test_command_context_converts_transport_error_to_system_exit(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values( { @@ -458,12 +459,13 @@ def test_command_context_converts_transport_error_to_system_exit(self) -> None: self.assertIn("Connecting to the device failed, SSH error: timeout", finished_payload["error"]) self.assertNotIn("SshError:", finished_payload["error"]) self.assertIn("Debug context:", finished_payload["error"]) - self.assertIn("ssh_opts=-L 108:127.0.0.1:108", finished_payload["error"]) + self.assertIn("ssh_opts=-L 108::108", finished_payload["error"]) + self.assertNotIn("127.0.0.1", finished_payload["error"]) def test_command_context_converts_config_error_to_system_exit(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") env_path = Path(tmp) / ".env" config = AppConfig.from_values({"TC_HOST": ""}, path=env_path, exists=True, file_values={}) with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): @@ -485,7 +487,7 @@ def test_command_context_converts_config_error_to_system_exit(self) -> None: def test_command_context_converts_device_error_to_system_exit(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values( { @@ -514,7 +516,7 @@ def test_command_context_converts_device_error_to_system_exit(self) -> None: def test_command_context_failure_without_error_gets_fallback_error(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -529,7 +531,7 @@ def test_command_context_failure_without_error_gets_fallback_error(self) -> None def test_command_context_labels_numeric_system_exit_error(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -544,7 +546,7 @@ def test_command_context_labels_numeric_system_exit_error(self) -> None: def test_command_context_captures_unexpected_exception_error(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -561,7 +563,7 @@ def test_command_context_captures_unexpected_exception_error(self) -> None: def test_command_context_still_finishes_when_debug_context_rendering_fails(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -579,7 +581,7 @@ def test_command_context_still_finishes_when_debug_context_rendering_fails(self) def test_command_context_debug_context_omits_password_values(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -601,7 +603,8 @@ def test_command_context_debug_context_omits_password_values(self) -> None: raise SystemExit("SSH authentication failed.") finished_payload = send_mock.call_args.args[0] self.assertIn("stage=ssh_probe", finished_payload["error"]) - self.assertIn("TC_HOST=root@192.168.1.217", finished_payload["error"]) + self.assertIn("TC_HOST=root@", finished_payload["error"]) + self.assertNotIn("192.168.1.217", finished_payload["error"]) self.assertIn("TC_SSH_OPTS=-o ProxyJump=bastion", finished_payload["error"]) self.assertIn("TC_INTERNAL_SHARE_USE_DISK_ROOT=true", finished_payload["error"]) self.assertNotIn("TC_PASSWORD", finished_payload["error"]) @@ -610,7 +613,7 @@ def test_command_context_debug_context_omits_password_values(self) -> None: def test_command_context_summarizes_debug_fields_when_recorded(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): @@ -638,7 +641,7 @@ def test_command_context_summarizes_debug_fields_when_recorded(self) -> None: def test_command_context_debug_context_includes_only_probe_fields_not_already_in_telemetry(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" - bootstrap_path.write_text("INSTALL_ID=test-install\n") + bootstrap_path.write_text("INSTALL_ID=test-install\nTELEMETRY=true\n") with mock.patch.dict(os.environ, {"TCAPSULE_TELEMETRY_TOKEN": "secret-token"}, clear=False): client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): diff --git a/tests/test_telemetry_exposure.py b/tests/test_telemetry_exposure.py new file mode 100644 index 00000000..cb581a8d --- /dev/null +++ b/tests/test_telemetry_exposure.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.core.redaction import REDACTED, scrub_telemetry_value +from timecapsulesmb.telemetry.operation import ( + telemetry_details_from_payload, + telemetry_options_from_params, +) + + +LAN_IP = "192.168.1.101" +SSH_TARGET = f"root@{LAN_IP}" +LOCAL_PATH = "/Volumes/Data/ShareRoot" + + +class TelemetryExposureTests(unittest.TestCase): + """Characterize what a telemetry payload is allowed to contain. + + These tests are the guardrail for the privacy hardening: raw LAN addresses, + SSH/SMB host targets, and local filesystem paths must never survive into an + emitted payload's options or details. + """ + + def _assert_no_network_or_path_leak(self, values: dict[str, object]) -> None: + serialized = repr(values) + self.assertNotIn(LAN_IP, serialized) + self.assertNotIn(SSH_TARGET, serialized) + self.assertNotIn(LOCAL_PATH, serialized) + + def test_configure_details_do_not_leak_host(self) -> None: + payload = { + "configure_id": "cfg-1", + "host": SSH_TARGET, + "ssh_authenticated": True, + "device_model": "TimeCapsule8,119", + "device_syap": "119", + "summary": f"Configured {SSH_TARGET}", + "compatibility": { + "payload_family": "samba4", + "os_name": "NetBSD", + "os_release": "6.0", + "arch": "evbarm", + }, + } + details = telemetry_details_from_payload("configure", {"enable_ssh": True}, payload) + self._assert_no_network_or_path_leak(details) + self.assertEqual(details.get("device_model"), "TimeCapsule8,119") + + def test_reachability_details_do_not_leak_hosts(self) -> None: + payload = { + "status": "ok", + "ssh_host": SSH_TARGET, + "smb_host": LAN_IP, + "summary": f"SSH reachable at {SSH_TARGET}", + } + details = telemetry_details_from_payload("reachability", {}, payload) + self._assert_no_network_or_path_leak(details) + self.assertEqual(details.get("status"), "ok") + + def test_fsck_details_do_not_leak_mount_paths(self) -> None: + payload = { + "target": {"name": "Data", "device": "/dev/dk2", "mountpoint": LOCAL_PATH}, + "returncode": 0, + "summary": f"Checked {LOCAL_PATH}", + } + details = telemetry_details_from_payload("fsck", {"volume": "dk2"}, payload) + self._assert_no_network_or_path_leak(details) + + def test_repair_xattrs_details_do_not_leak_root_path(self) -> None: + payload = { + "root": LOCAL_PATH, + "finding_count": 2, + "repairable_count": 2, + "summary_text": f"Scanned {LOCAL_PATH}", + } + details = telemetry_details_from_payload("repair-xattrs", {}, payload) + self._assert_no_network_or_path_leak(details) + self.assertEqual(details.get("finding_count"), 2) + + def test_options_do_not_leak_free_text_hosts(self) -> None: + options = telemetry_options_from_params({"yes": True, "dry_run": False, "mount_wait": 30}) + self._assert_no_network_or_path_leak(options) + self.assertTrue(options.get("yes")) + + def test_custom_objects_are_stringified_and_scrubbed(self) -> None: + # A non-primitive (e.g. an exception) must be scrubbed before json.dumps + # can stringify it and bypass redaction. + error = RuntimeError(f"connect failed at {LAN_IP} while opening {LOCAL_PATH}") + scrubbed = scrub_telemetry_value(error) + self.assertIsInstance(scrubbed, str) + self.assertNotIn(LAN_IP, scrubbed) + self.assertNotIn(LOCAL_PATH, scrubbed) + + def test_path_with_spaces_is_fully_redacted(self) -> None: + scrubbed = scrub_telemetry_value(Path("/Volumes/Data/My Backup Folder/file.txt")) + self.assertEqual(scrubbed, REDACTED) + + def test_primitives_are_preserved(self) -> None: + self.assertEqual(scrub_telemetry_value(42), 42) + self.assertIs(scrub_telemetry_value(True), True) + self.assertIsNone(scrub_telemetry_value(None)) + self.assertEqual(scrub_telemetry_value(1.5), 1.5) + + +if __name__ == "__main__": + unittest.main()