Files
siftlode/backend/tests/test_log_pii.py
T
peter 54d19cc386 fix(logs): deep-review round 1 — say each failure once, and scrub it everywhere
- yt-dlp's text is scrubbed wherever it is logged, not only in the adapter:
  `formats.log_safe` takes out the URL, the `[extractor] <id>:` prefix and
  file paths, and the job-failed, refused, edit-failed and backfill lines use
  it. Done lines log the asset id, not the title-built path. The PII contract
  now bans `rel`/`rel_path`/`title`.
- A traceback goes with a download/edit failure only when it is our own code
  failing; for yt-dlp's DownloadError, our RuntimeErrors and OSErrors its foot
  is the unscrubbed message.
- yt-dlp's `error` maps to WARNING: it fires on attempts the fallback client
  set recovers from; the job's verdict is `download.job.failed`.
- The sink groups a bare `%s` pass-through by its text (numbers folded), and
  a process's last batch closes the repeat window, so a short-lived download
  child still reports what it dropped.
- Worker-side failures (stall, wall-clock, no asset, cached asset error) log
  `download.job.failed` with a cause; they left no line at all before. The
  crash, supervisor-error and non-zero-exit lines carry the code too.
- An edit failure is one coded line in `_fail_edit`, for every path.
- `logs.get_logger("__main__")` resolves to the `-m` module, so the worker
  and the download child keep their names.
- TRACE lines carrying user content are gated on `logs.consented_trace()`,
  not on the process level; a contract test pins it.
- The OAuth token refresh goes through `_upstream_failure`; transport errors
  carry reason `transport`. Subscribe recognises a duplicate by reason code
  only (the prose fallback was dead).
- Once-only guards for a stale sysconfig override and a refusing HLS session
  directory; item_detail splits Plex failures from our own parse bugs.
- The playlist breaker line names the playlist and the action; comments that
  were false (runner noqa, `_merge_vtt`, sink grouping) are corrected.
2026-09-18 03:16:15 +02:00

302 lines
11 KiB
Python

"""E22.5 S1 — what a log line may NOT carry.
`logs.redact_text` is a safety net over addresses and credential params, and it was doing the whole
job: every one of these call sites was putting user content into a line and relying on the net to
catch it. Two things were wrong with that. What the net does NOT cover went in whole — a search
term, a source URL, an upstream body that quotes the request back — and what it DOES cover came out
as `[email]`, so the line's one variable was a constant and the line said nothing.
The cases below pin the call sites. The last one pins the family: no future log call may pass the
same kind of value without saying, on the line, why it is safe.
"""
import ast
import json
import logging
import pathlib
import httpx
import pytest
from app import logs
from app.youtube.client import YouTubeError, _upstream_failure
TERM = "the exact thing they typed"
class _Sink(logging.Handler):
"""The stdout handler's own filter and formatter, so a case sees the bytes a deployment writes
— not the record before redaction ran."""
def __init__(self):
super().__init__(level=logging.NOTSET)
self.lines: list[str] = []
self.setFormatter(logs.JsonFormatter())
self.addFilter(logs.ContextFilter())
def emit(self, record):
self.lines.append(self.format(record))
def objects(self) -> list[dict]:
return [json.loads(line) for line in self.lines]
def at(self, level: str) -> list[dict]:
return [o for o in self.objects() if o["level"] == level]
@pytest.fixture
def sink():
logger = logging.getLogger("siftlode")
handler = _Sink()
previous = logger.level
logger.addHandler(handler)
logger.setLevel(logs.TRACE)
try:
yield handler
finally:
logger.removeHandler(handler)
logger.setLevel(previous)
def _youtube_403(term: str) -> httpx.Response:
"""A real-shaped Data API error. `message` is prose, and prose from an upstream quotes the
request back — which is how a search term ends up in our log through someone else's words."""
return httpx.Response(
403,
json={
"error": {
"code": 403,
"message": f"The request for '{term}' cannot be completed: quota exceeded.",
"errors": [{"reason": "quotaExceeded", "domain": "youtube.quota"}],
}
},
request=httpx.Request("GET", "https://www.googleapis.com/youtube/v3/search"),
)
def test_an_upstream_failure_logs_the_reason_code_and_not_the_body(sink):
err = _upstream_failure("GET", "search", _youtube_403(TERM))
(line,) = sink.at("WARNING")
assert line["attrs"]["reason"] == "quotaExceeded"
assert line["attrs"]["status"] == 403
assert TERM not in json.dumps(line)
# The error is raised to callers that put it in a log line of their own (routes/channels.py)
# and in a 422 answered to the user, so the body must not travel in it either.
assert TERM not in str(err)
assert err.reason == "quotaExceeded"
@pytest.fixture
def consented():
"""The request scope of a user who turned Teljes (TRACE) on for themselves."""
scope = logs._RequestScope()
scope.user_id, scope.level = 7, logs.TRACE
token = logs._scope.set(scope)
try:
yield
finally:
logs._scope.reset(token)
def test_the_body_is_kept_for_the_one_user_who_consented_to_it(sink, consented):
_upstream_failure("GET", "search", _youtube_403(TERM))
# The diagnostic is not lost, it is gated — on the consent the privacy policy describes.
(trace,) = sink.at("TRACE")
assert TERM in trace["attrs"]["body"]
def test_a_process_running_at_trace_is_not_anyones_consent(sink):
"""`sink` runs the whole tree at TRACE — what an admin setting `log_level` to TRACE does. That
buys more detail, not every user's search terms quoted back (`logs._traced`)."""
_upstream_failure("GET", "search", _youtube_403(TERM))
assert sink.at("TRACE") == []
assert TERM not in json.dumps(sink.objects())
def test_a_scope_with_no_elevation_is_not_consent():
scope = logs._RequestScope()
scope.user_id = 7
token = logs._scope.set(scope)
try:
assert not logs.consented_trace()
finally:
logs._scope.reset(token)
assert not logs.consented_trace() # a scheduler thread: no request at all
def test_a_refused_token_refresh_names_the_oauth_reason_and_not_its_prose(sink):
"""The token endpoint's error is `{"error": "<code>", "error_description": <prose>}` — not the
Data API's shape. It goes through the same door, so it gets the same treatment."""
resp = httpx.Response(
400,
json={"error": "invalid_grant", "error_description": "Token has been expired or revoked."},
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
)
err = _upstream_failure("POST", "oauth2/token", resp)
(line,) = sink.at("WARNING")
assert line["attrs"]["reason"] == "invalid_grant"
assert err.reason == "invalid_grant"
assert "revoked" not in str(err) and "revoked" not in json.dumps(line)
def test_an_unparseable_body_still_names_the_status(sink):
"""A proxy answering HTML has no `reason` to take — the line must still be worth reading, and
must still not fall back to printing the body."""
resp = httpx.Response(
502,
text="<html><body>Bad Gateway</body></html>",
request=httpx.Request("GET", "https://www.googleapis.com/youtube/v3/search"),
)
err = _upstream_failure("GET", "search", resp)
(line,) = sink.at("WARNING")
assert line["attrs"] == {
"method": "GET",
"path": "search",
"status": 502,
"reason": "<unparseable>",
}
assert "Bad Gateway" not in str(err)
assert isinstance(err, YouTubeError)
def test_a_sign_in_names_the_account_by_id_and_not_by_address(client, make_user, sink):
make_user(email="signing.in@example.com", password="correct horse battery")
r = client.post(
"/auth/password-login",
json={"email": "signing.in@example.com", "password": "correct horse battery"},
)
assert r.status_code == 200
(line,) = [o for o in sink.at("INFO") if o.get("event") == "auth.login.ok"]
assert line["attrs"]["method"] == "password"
assert isinstance(line["attrs"]["uid"], int)
# Not `[email]` either: the redacted form is what made the old line useless.
assert "signing.in" not in json.dumps(line)
assert logs.REDACTED_EMAIL not in json.dumps(line)
# --- the family ------------------------------------------------------------------------------
# Names that hold something a person typed or a place they went. A log call may still pass one —
# `main.py` logs the instance's OWN setup URL — but it has to say so on the line, which is the
# difference between a decision and an oversight.
_BANNED_NAMES = frozenset(
{
"term",
"q",
"query",
"search_term",
"email",
"emails",
"recipients",
"recipient",
"url",
"webpage_url",
"source_url",
"source_webpage_url",
"password",
# A download's path is built from its channel and its title (`storage.rel_path`), so it
# names what someone downloaded exactly as the URL does. Log the asset id.
"rel",
"rel_path",
"title",
}
)
_BANNED_ATTRS = frozenset(
{"text", "content", "email", "url", "query", "rel_path", "title", "display_name"}
)
_ESCAPE = "log-pii-ok"
_LOG_METHODS = frozenset(
{"debug", "info", "warning", "warn", "error", "exception", "critical", "log"}
)
def _is_log_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
return False
if node.func.attr not in _LOG_METHODS:
return False
base, name = node.func.value, ""
while isinstance(base, ast.Attribute):
name = f"{base.attr}.{name}"
base = base.value
if isinstance(base, ast.Name):
name = f"{base.id}.{name}"
return "log" in name.lower()
def _offending(node: ast.Call) -> set[str]:
found: set[str] = set()
for sub in ast.walk(node):
if isinstance(sub, ast.Name) and sub.id in _BANNED_NAMES:
found.add(sub.id)
elif isinstance(sub, ast.Attribute) and sub.attr in _BANNED_ATTRS:
found.add(f".{sub.attr}")
return found
def test_no_log_call_passes_what_a_person_typed():
root = pathlib.Path(__file__).resolve().parents[1] / "app"
offenders = []
for path in sorted(root.rglob("*.py")):
src = path.read_text(encoding="utf-8")
lines = src.splitlines()
for node in ast.walk(ast.parse(src)):
if not _is_log_call(node):
continue
names = _offending(node)
if not names:
continue
# The call's own lines plus the one directly above it — a marker belongs either
# on the argument or on the comment line introducing the call, and nowhere
# further, or one call's justification would excuse its neighbour.
start = max(0, node.lineno - 2)
span = " ".join(lines[start : (node.end_lineno or node.lineno)])
if _ESCAPE in span:
continue
offenders.append(f"{path.relative_to(root)}:{node.lineno} {sorted(names)}")
assert offenders == [], (
"a log call carries user-typed content; log an id or a reason code instead, or mark "
f"the argument (or the line directly above the call) `# {_ESCAPE}: <why it is "
f"safe>` — {offenders}"
)
def _guarded_by_consent(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> bool:
while node in parents:
node = parents[node]
if isinstance(node, ast.If):
for sub in ast.walk(node.test):
if isinstance(sub, ast.Attribute) and sub.attr == "consented_trace":
return True
return False
def test_every_escaped_trace_line_is_gated_on_the_users_own_consent():
"""`log-pii-ok` on a TRACE line means "this is what the user's Teljes consent is for". The TRACE
level alone does not hold that promise: an admin can run the whole process at TRACE, and a
scheduler thread has no user at all. So the line must sit under `logs.consented_trace()`."""
root = pathlib.Path(__file__).resolve().parents[1] / "app"
offenders = []
for path in sorted(root.rglob("*.py")):
src = path.read_text(encoding="utf-8")
lines = src.splitlines()
tree = ast.parse(src)
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
for node in ast.walk(tree):
if not _is_log_call(node) or not _offending(node):
continue
first = node.args[0] if node.args else None
at_trace = isinstance(first, ast.Attribute) and first.attr == "TRACE"
span = " ".join(lines[max(0, node.lineno - 2) : (node.end_lineno or node.lineno)])
if at_trace and _ESCAPE in span and not _guarded_by_consent(node, parents):
offenders.append(f"{path.relative_to(root)}:{node.lineno}")
assert offenders == [], (
f"a TRACE line with user content is not under consented_trace(): {offenders}"
)