fix(logs): keep what a person typed out of the backend log lines

The E22 redactor is a safety net over addresses and credential params, and every
one of these call sites was leaning on it to do the whole job. What it does not
cover went in whole, and what it does cover came out as a constant.

Not covered, so a real leak:
- routes/search.py logged the raw search term at ERROR. The privacy policy shipped
  in 0.88.0 says a search string is kept only at Teljes/TRACE, with the user's own
  consent. The ERROR line now carries the shape of the failure (source, candidate
  and collected counts) and the term follows on a TRACE line, which nothing emits
  unless that consent is in force.
- downloads/backfill.py logged the full source URL at INFO — a row of the user's
  download history. Now the asset id and the host.
- youtube/client.py logged the upstream error body, which is prose that quotes the
  request back. Every failing YouTube call now goes through one _upstream_failure()
  that logs the API's `reason` code, keeps the body for the same consented TRACE
  line, and raises a YouTubeError carrying `reason` as a field — callers put that
  error in log lines of their own (routes/channels.py) and in a 422 answered to the
  user, so the body must not travel in it either.

Covered, so merely useless: routes/auth.py named the account by an address that
reached both sinks as `[email]`, and app/email.py printed recipient lists that read
as `['[email]']`. Both now carry the uid (or a count) and a reason code, which is
also the first half of E22.5 S4's failed-sign-in coverage.

routes/channels.py branched on the text of that error message to recognise an
already-existing subscription. It now branches on `exc.reason`, with the substring
check kept beside it for a body that carried no reason at all.

tests/test_log_pii.py pins the call sites and the family: a source-level contract
test walks every log call in app/ and fails on an argument naming user-typed
content unless the line says why it is safe (`# log-pii-ok: <why>`). Eight sites
carry that marker today, each with its reason. Gate 8/8 green; all five cases
mutation-checked.
This commit is contained in:
2026-09-18 00:49:43 +02:00
parent 8c10e57208
commit dc221338be
8 changed files with 364 additions and 32 deletions
+213
View File
@@ -0,0 +1,213 @@
"""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"
def test_the_body_is_kept_for_the_one_user_who_consented_to_it(sink):
_upstream_failure("GET", "search", _youtube_403(TERM))
# TRACE is emitted only while the user has turned Teljes on for their own session, which is
# exactly the consent the privacy policy describes. The diagnostic is not lost, it is gated.
(trace,) = sink.at("TRACE")
assert TERM in trace["attrs"]["body"]
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",
}
)
_BANNED_ATTRS = frozenset({"text", "content", "email", "url", "query"})
_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}"
)