test_log_credential_redaction.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. """Credentials must never reach bambuddy.log.
  2. Subprocesses echo their input URL back at us: ffmpeg prints the RTSP input in
  3. its ``Input #0`` line, so logging its stderr verbatim published the printer
  4. access code (or an external camera's password) into the log file — which users
  5. routinely attach to public GitHub issues.
  6. These cover the shared helper plus the two funnels that carry subprocess output
  7. into the log.
  8. """
  9. import asyncio
  10. import time
  11. from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
  12. from backend.app.core.logging_filters import redact_url_credentials
  13. from backend.app.services.log_reader import sanitize_log_content
  14. # What ffmpeg actually prints for the camera's local TLS-proxy input. The
  15. # access code sits in the userinfo of the URL it quotes back.
  16. FFMPEG_INPUT_LINE = "Input #0, rtsp, from 'rtsp://bblp:38A4KQ2P@127.0.0.1:48521/streaming/live/1':"
  17. class TestRedactUrlCredentials:
  18. def test_masks_the_printer_access_code(self):
  19. result = redact_url_credentials(FFMPEG_INPUT_LINE)
  20. assert "38A4KQ2P" not in result
  21. assert result == "Input #0, rtsp, from 'rtsp://bblp:[REDACTED]@127.0.0.1:48521/streaming/live/1':"
  22. def test_keeps_everything_that_is_not_the_secret(self):
  23. """Host, port, path and username stay — the line has to remain diagnosable."""
  24. result = redact_url_credentials("rtsp://admin:hunter2@192.168.1.50:554/stream1")
  25. assert result == "rtsp://admin:[REDACTED]@192.168.1.50:554/stream1"
  26. def test_masks_every_scheme_not_just_the_ones_we_use_today(self):
  27. for url, expected in (
  28. ("http://user:pw@cam.local/snapshot", "http://user:[REDACTED]@cam.local/snapshot"),
  29. ("https://user:pw@cam.local/snapshot", "https://user:[REDACTED]@cam.local/snapshot"),
  30. ("rtsps://bblp:code@printer:322/streaming/live/1", "rtsps://bblp:[REDACTED]@printer:322/streaming/live/1"),
  31. ("ftp://bblp:code@printer:990/", "ftp://bblp:[REDACTED]@printer:990/"),
  32. ):
  33. assert redact_url_credentials(url) == expected
  34. def test_masks_a_password_containing_an_at_sign(self):
  35. """The userinfo ends at the LAST @ before the path — no tail may survive."""
  36. result = redact_url_credentials("rtsp://admin:p@ssw0rd@192.168.1.50/stream")
  37. assert result == "rtsp://admin:[REDACTED]@192.168.1.50/stream"
  38. assert "ssw0rd" not in result
  39. def test_masks_several_urls_in_one_blob(self):
  40. text = "first rtsp://bblp:AAAAAAAA@10.0.0.1/live then rtsp://bblp:BBBBBBBB@10.0.0.2/live"
  41. result = redact_url_credentials(text)
  42. assert "AAAAAAAA" not in result
  43. assert "BBBBBBBB" not in result
  44. assert result.count("[REDACTED]") == 2
  45. def test_never_runs_past_the_authority_into_the_path(self):
  46. """A later @ in the path must not drag the host into the mask."""
  47. result = redact_url_credentials("rtsp://bblp:code@10.0.0.1/live/user@example")
  48. assert result == "rtsp://bblp:[REDACTED]@10.0.0.1/live/user@example"
  49. def test_leaves_credential_free_text_alone(self):
  50. for untouched in (
  51. "Connection refused",
  52. "rtsp://10.0.0.1:554/stream1",
  53. "mailto and user@example.com in prose",
  54. "Starting USB camera stream from /dev/video0 at 10 fps",
  55. ):
  56. assert redact_url_credentials(untouched) == untouched
  57. def test_tolerates_empty_and_none(self):
  58. assert redact_url_credentials("") == ""
  59. assert redact_url_credentials(None) is None
  60. def test_a_long_scheme_like_run_does_not_blow_up(self):
  61. """The scheme repetition is capped so the match stays linear.
  62. Unbounded, the engine restarted at every offset of a run of
  63. scheme-legal characters and consumed to the end each time before
  64. failing to find ``://`` — quadratic in the length of the line, and
  65. ffmpeg echoes the operator's camera URL into the subject. An absolute
  66. timing bound would be flaky, so this pins the growth rate instead:
  67. doubling the input must not quadruple the work. Measured against the
  68. unbounded pattern, these two inputs took 550ms and 2187ms (ratio 3.97,
  69. so the assertion fails); bounded, 2.8ms and 5.4ms (ratio 1.98).
  70. """
  71. small = "A" * 32_000 + "://@"
  72. large = "A" * 64_000 + "://@"
  73. start = time.perf_counter()
  74. assert redact_url_credentials(small) == small
  75. small_elapsed = time.perf_counter() - start
  76. start = time.perf_counter()
  77. assert redact_url_credentials(large) == large
  78. large_elapsed = time.perf_counter() - start
  79. # Linear would be ~2x. Allow generous slack for a loaded CI box while
  80. # still failing the ~4x of a quadratic match.
  81. assert large_elapsed < max(small_elapsed * 3, 0.5)
  82. def test_a_scheme_longer_than_the_cap_still_gets_its_secret_masked(self):
  83. """The cap bounds backtracking; it must not create a redaction hole.
  84. A pseudo-scheme longer than the cap simply matches from a later
  85. offset, so the password is still replaced.
  86. """
  87. result = redact_url_credentials("Z" * 100 + "://user:hunter2@host/path")
  88. assert "hunter2" not in result
  89. assert result.endswith("://user:[REDACTED]@host/path")
  90. class TestFfmpegStderrFunnel:
  91. """`_summarize_ffmpeg_stderr` is the one funnel every stderr log in the
  92. camera route passes through, so redaction lands there."""
  93. def test_summary_strips_the_access_code(self):
  94. stderr = f"{FFMPEG_INPUT_LINE}\n[rtsp @ 0x5] Could not find codec parameters\n"
  95. result = _summarize_ffmpeg_stderr(stderr)
  96. assert "38A4KQ2P" not in result
  97. assert "[REDACTED]" in result
  98. # The actionable error is untouched.
  99. assert "Could not find codec parameters" in result
  100. def test_incremental_reader_strips_the_access_code(self):
  101. async def run():
  102. reader = asyncio.StreamReader()
  103. reader.feed_data(f"{FFMPEG_INPUT_LINE}\nError opening input: Connection refused\n".encode())
  104. reader.feed_eof()
  105. class _FakeProcess:
  106. stderr = reader
  107. return await _read_ffmpeg_stderr(_FakeProcess())
  108. result = asyncio.run(run())
  109. assert result is not None
  110. assert "38A4KQ2P" not in result
  111. assert "Connection refused" in result
  112. class TestSupportBundleSanitizerUnchanged:
  113. """The bundle sanitizer shares the pattern but keeps its own, stricter
  114. replacement — it drops the username too. Guard against drift."""
  115. def test_bundle_still_drops_the_whole_userinfo(self):
  116. result = sanitize_log_content("rtsp://bblp:38A4KQ2P@10.0.0.1/live")
  117. assert "38A4KQ2P" not in result
  118. assert "bblp" not in result
  119. assert "[CREDENTIALS]@" in result