diff --git a/aider/analytics.py b/aider/analytics.py index f3eb071c336..021877de221 100644 --- a/aider/analytics.py +++ b/aider/analytics.py @@ -1,9 +1,11 @@ import json import platform +import re import sys import time import uuid from pathlib import Path +from urllib.parse import urlsplit, urlunsplit from mixpanel import MixpanelException from posthog import Posthog @@ -13,6 +15,47 @@ from aider.models import model_info_manager PERCENT = 10 +_ANALYTICS_MAX_TEXT_LENGTH = 500 +_URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s<>\"']+") +_ABSOLUTE_PATH_RE = re.compile( + r"(?\"']*" +) +_SENSITIVE_ASSIGNMENT_RE = re.compile( + r"(?i)(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)\b" + r"\s*[:=]\s*['\"]?)[^'\"\s,}&]+" +) + + +def _redact_url(match): + value = match.group(0) + trailing = "" + while value and value[-1] in ".,;)]}": + trailing = value[-1] + trailing + value = value[:-1] + + try: + parsed = urlsplit(value) + except ValueError: + return "[REDACTED_URL]" + trailing + + if parsed.scheme.lower() in {"http", "https"}: + if parsed.query or parsed.fragment: + value = urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, "REDACTED", "") + ) + return value + trailing + + return "[REDACTED_URL]" + trailing + + +def _redact_sensitive_text(value): + """Remove common secrets and local paths before analytics leaves the process.""" + value = _URL_RE.sub(_redact_url, str(value)) + value = _SENSITIVE_ASSIGNMENT_RE.sub(r"\1[REDACTED]", value) + value = _ABSOLUTE_PATH_RE.sub("[REDACTED_PATH]", value) + if len(value) > _ANALYTICS_MAX_TEXT_LENGTH: + value = value[:_ANALYTICS_MAX_TEXT_LENGTH] + "..." + return value def compute_hex_threshold(percent): @@ -223,12 +266,12 @@ def event(self, event_name, main_model=None, **kwargs): properties.update(kwargs) - # Handle numeric values + # Keep numeric metrics numeric, but scrub every free-form value at the boundary. for key, value in properties.items(): if isinstance(value, (int, float)): properties[key] = value else: - properties[key] = str(value) + properties[key] = _redact_sensitive_text(value) if self.mp: try: @@ -237,7 +280,9 @@ def event(self, event_name, main_model=None, **kwargs): self.mp = None # Disable mixpanel on connection errors if self.ph: - self.ph.capture(event_name, distinct_id=self.user_id, properties=dict(properties)) + self.ph.capture( + event_name, distinct_id=self.user_id, properties=dict(properties) + ) if self.logfile: log_entry = { diff --git a/tests/basic/test_analytics.py b/tests/basic/test_analytics.py index e3178ee30ee..d8c949abc6d 100644 --- a/tests/basic/test_analytics.py +++ b/tests/basic/test_analytics.py @@ -79,7 +79,31 @@ def test_analytics_event_logging(temp_analytics_file, temp_data_dir): assert "test_key" in log_entry["properties"] -def test_system_info(temp_data_dir): +def test_event_scrubs_sensitive_text_at_boundary(temp_analytics_file, temp_data_dir): + analytics = Analytics(logfile=temp_analytics_file) + analytics.event( + "test_event", + reason=( + "request https://api.example.test/v1?api_key=not-a-real-key " + "failed at /Users/example/project" + ), + model="/private/model", + attempts=3, + ratio=0.5, + label="plain value", + ) + + with open(temp_analytics_file) as f: + properties = json.loads(f.read().strip())["properties"] + + assert "not-a-real-key" not in properties["reason"] + assert "/Users/example/project" not in properties["reason"] + assert "https://api.example.test/v1" in properties["reason"] + assert properties["model"] == "[REDACTED_PATH]" + assert properties["attempts"] == 3 + assert properties["ratio"] == 0.5 + assert properties["label"] == "plain value" + analytics = Analytics() sys_info = analytics.get_system_info()