Просмотр исходного кода

fix(logs): redact LDAP Distinguished Names from support bundle (#2681)

With LDAP auth in use, the debug log carried the full user DN on successful
auth -- e.g. "(DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, ...)". A DN's
leaf CN is the user's real name, PII on par with the email address already
redacted, and it passed straight into an uploaded support bundle. The log
sanitizer (shared by the support bundle and the in-app bug report) had no DN
pattern; DNs also leak via ldap3 exception strings and group-mapping logs.

- sanitize_log_content: redact LDAP DNs to [DN] -- a run of >=2 attr=value RDN
  components (CN/OU/DC/UID/...). The value class excludes <>;+ (RFC 4514 requires
  them escaped in a value) so the final comma-unbounded component doesn't swallow
  trailing log text such as "-> GroupName". Ordinary key=value lines are untouched.
- ldap_service: stop logging the raw DN on successful auth (username + group
  count suffices), keeping the PII off disk even before bundle sanitization.
maziggy 1 месяц назад
Родитель
Сommit
561e94b755

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 6 - 2
backend/app/services/ldap_service.py

@@ -256,10 +256,14 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             return None
 
         info = _extract_user_info(service_conn, config, user_entry, username)
+        # Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
+        # The username + group count is enough to confirm a successful auth; the
+        # support-bundle sanitizer also redacts any DN that slips through (e.g. an
+        # ldap3 exception string), but keeping it out of the log at the source is
+        # the primary hygiene per the "no private data in logs" rule.
         logger.info(
-            "LDAP authentication successful for user: %s (DN: %s, groups: %d)",
+            "LDAP authentication successful for user: %s (groups: %d)",
             info.username,
-            user_dn,
             len(info.groups),
         )
         return info

+ 18 - 0
backend/app/services/log_reader.py

@@ -25,6 +25,21 @@ logger = logging.getLogger(__name__)
 # parse it out; the log-health scanner does not.
 LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
 
+# LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
+# (#2681). Match a run of at least two ``attr=value`` RDN components joined by
+# commas, where ``attr`` is a known LDAP attribute type. Requiring two components
+# keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
+# while still catching DNs wherever they surface — the deliberate "auth successful"
+# line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
+# redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
+# a name is not.
+# The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
+# DN value, so an unescaped one marks the end of the DN, not part of it. That stops
+# the final (comma-unbounded) component from greedily swallowing trailing log text
+# such as ``… -> GroupName``.
+_LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
+_LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
+
 
 class LogEntry(BaseModel):
     """A single parsed log entry."""
@@ -159,6 +174,9 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
+    # Replace LDAP Distinguished Names (#2681) — PII on par with email.
+    content = _LDAP_DN_PATTERN.sub("[DN]", content)
+
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
     content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
 

+ 49 - 0
backend/tests/unit/test_support_helpers.py

@@ -330,6 +330,55 @@ class TestSanitizeLogContent:
         assert "/home/[user]/" in result
         assert "[IP]" in result
 
+    def test_ldap_dn_redacted_reporter_line(self):
+        """#2681: the exact reporter line — the CN (real name) must not survive."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = (
+            "LDAP authentication successful for user: jschmoe "
+            "(DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, groups: 4)"
+        )
+        result = _sanitize_log_content(content)
+        assert "Joe Schmoe" not in result
+        assert "DC=example" not in result
+        assert result == "LDAP authentication successful for user: jschmoe (DN: [DN], groups: 4)"
+
+    def test_ldap_dn_redacted_in_exception_string(self):
+        """DNs that leak indirectly via ldap3 exception text are caught too."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "LDAP bind failed for user jschmoe: invalidCredentials at uid=jschmoe,ou=people,dc=example,dc=org"
+        result = _sanitize_log_content(content)
+        assert "uid=jschmoe" not in result
+        assert "[DN]" in result
+
+    def test_ldap_group_dn_redacted(self):
+        """Group DNs (from group-mapping logs) are PII-bearing and redacted."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Mapped CN=Admins,OU=Groups,DC=corp,DC=local -> Administrators"
+        result = _sanitize_log_content(content)
+        assert "CN=Admins" not in result
+        assert "DC=corp" not in result
+        assert "[DN]" in result
+        assert "Administrators" in result  # the non-PII target group name survives
+
+    def test_non_dn_key_value_line_not_clobbered(self):
+        """An ordinary key=value log line must not be mistaken for a DN."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Dispatch decision: mode=queue, state=FINISH, printer=1"
+        result = _sanitize_log_content(content)
+        assert result == content
+
+    def test_single_rdn_not_redacted(self):
+        """A lone attr=value (not a multi-component DN) is left alone."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Country C=US selected"
+        result = _sanitize_log_content(content)
+        assert result == content
+
 
 class TestCollectSupportInfo:
     """Tests for _collect_support_info() new diagnostic sections."""

Некоторые файлы не были показаны из-за большого количества измененных файлов