7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
310 lines
13 KiB
Python
310 lines
13 KiB
Python
"""AST invariant: no traceback-rendering log call with a password in scope.
|
|
|
|
Companion to ``test_password_leakage.py`` (issue #4182, PR #4530). That
|
|
file proves representative handlers redact correctly at runtime; this one
|
|
mechanically enforces the sweep's contract across ALL handlers, so a new
|
|
``logger.exception`` (or ``logger.debug(..., exc_info=True)``) added to a
|
|
credential-bearing function fails CI instead of silently re-opening the
|
|
leak.
|
|
|
|
Why this matters: loguru ``diagnose=True`` renders frame locals into the
|
|
exception block. Any function that holds the user's SQLCipher master
|
|
password in a local/parameter and renders a traceback can therefore
|
|
persist the plaintext password to log sinks. Unlike API keys, the master
|
|
password is unrecoverable (TRUST.md §5).
|
|
|
|
The required pattern at such sites is::
|
|
|
|
except Exception as e:
|
|
safe_msg = redact_secrets(str(e), password)
|
|
logger.warning(f"... {safe_msg}")
|
|
|
|
Scope and limitations (deliberate, keep in sync with the sweep):
|
|
|
|
- Only the modules in ``SWEPT_MODULES`` are checked: the files swept by
|
|
#4530 plus the password-bearing session helpers added in the #4182
|
|
follow-up. Extend ``SWEPT_MODULES`` when a new module starts handling
|
|
the master password.
|
|
- A "password name" is any identifier containing ``password`` (case-
|
|
insensitive), excluding ``session_password_store`` (a module-level store
|
|
object, not a credential value).
|
|
- The check is per innermost function: a traceback-rendering log call is a
|
|
violation if any password name is bound anywhere in the same function.
|
|
This is slightly conservative (the name might be bound on a disjoint
|
|
branch) — that is fine; the fix is cheap and the false-negative
|
|
direction is the dangerous one.
|
|
|
|
Known blind spots (none triggered by the swept files today; keep logging at
|
|
the top function level and rooted at the bare ``logger`` so they stay safe):
|
|
|
|
- Nested/inner functions: the check attributes a log call to its *innermost*
|
|
enclosing function. A traceback log inside a nested function that closes
|
|
over an outer ``password`` is NOT flagged, even though diagnose=True
|
|
renders outer frames too. Don't put traceback logs in inner functions of
|
|
password-bearing functions.
|
|
- Aliased loggers: only call chains rooted at the literal name ``logger`` are
|
|
inspected. ``from loguru import logger as log`` (or stdlib ``logging``)
|
|
would evade the check. The swept files all use the bare ``logger``.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import local_deep_research.database.encrypted_db as encrypted_db_module
|
|
import local_deep_research.scheduler.background as background_module
|
|
import local_deep_research.web.queue.processor_v2 as processor_v2_module
|
|
import local_deep_research.database.thread_local_session as thread_local_session_module
|
|
import local_deep_research.library.download_management.status_tracker as status_tracker_module
|
|
import local_deep_research.database.thread_metrics as thread_metrics_module
|
|
import local_deep_research.metrics.search_tracker as search_tracker_module
|
|
import local_deep_research.metrics.token_counter as token_counter_module
|
|
import local_deep_research.database.library_init as library_init_module
|
|
import local_deep_research.database.backup.backup_executor as backup_executor_module
|
|
import local_deep_research.web_search_engines.rate_limiting.tracker as rate_limit_tracker_module
|
|
import local_deep_research.research_library.search.services.research_history_indexer as research_history_indexer_module
|
|
|
|
SWEPT_MODULES = [
|
|
encrypted_db_module,
|
|
background_module,
|
|
processor_v2_module,
|
|
# Consumers of ``DatabaseInitializationError`` that hold the master
|
|
# password in scope while logging the catch (#4182 follow-up): the
|
|
# raise site redacts, but these callers re-render via the logger.
|
|
thread_local_session_module,
|
|
status_tracker_module,
|
|
# Holds the master password in scope while opening a per-thread
|
|
# metrics session and logging the failure (#4182 follow-up).
|
|
thread_metrics_module,
|
|
# Credential-centric helpers that open an encrypted session with the
|
|
# master password and log failures (#4182 targeted sweep). The big
|
|
# multi-purpose route/service modules are NOT listed: they are
|
|
# protected at the sink level (log_utils forces diagnose=False on the
|
|
# persisted DB / frontend sinks) so their unrelated-error tracebacks
|
|
# stay useful.
|
|
search_tracker_module,
|
|
token_counter_module,
|
|
library_init_module,
|
|
backup_executor_module,
|
|
rate_limit_tracker_module,
|
|
research_history_indexer_module,
|
|
]
|
|
|
|
# Names that match the password heuristic but are not credential values.
|
|
_ALLOWED_PASSWORD_NAMES = {"session_password_store"}
|
|
|
|
|
|
def _password_names_in(func_node: ast.AST) -> set:
|
|
"""Collect credential-looking identifiers bound or used in *func_node*."""
|
|
names = set()
|
|
for node in ast.walk(func_node):
|
|
if isinstance(node, ast.Name) and "password" in node.id.lower():
|
|
names.add(node.id)
|
|
if isinstance(node, ast.arg) and "password" in node.arg.lower():
|
|
names.add(node.arg)
|
|
return names - _ALLOWED_PASSWORD_NAMES
|
|
|
|
|
|
def _logger_method_name(call: ast.Call):
|
|
"""If *call* is a method call ultimately rooted at the bare ``logger``
|
|
name, return the final method name (e.g. ``"exception"``, ``"debug"``);
|
|
otherwise ``None``.
|
|
|
|
Follows chained loguru calls so ``logger.bind(...).debug(...)`` and
|
|
``logger.opt(...).exception(...)`` resolve to ``"debug"`` /
|
|
``"exception"`` — that ``.bind()`` form is used live in the swept files
|
|
(scheduler/background.py), so missing it would leave a real blind spot.
|
|
|
|
Aliased loggers (``from loguru import logger as log``) are NOT followed —
|
|
the root must be the literal name ``logger``. None of the swept files
|
|
alias it; see the limitation note in the module docstring.
|
|
"""
|
|
func = call.func
|
|
if not isinstance(func, ast.Attribute):
|
|
return None
|
|
method = func.attr
|
|
node = func.value
|
|
# Walk the receiver chain to its root, stepping through intermediate
|
|
# method calls (logger.bind(...)/opt(...)) and attribute accesses.
|
|
while True:
|
|
if isinstance(node, ast.Name):
|
|
return method if node.id == "logger" else None
|
|
if isinstance(node, ast.Call):
|
|
node = node.func
|
|
elif isinstance(node, ast.Attribute):
|
|
node = node.value
|
|
else:
|
|
return None
|
|
|
|
|
|
def _renders_traceback(call: ast.Call) -> bool:
|
|
"""True if *call* is a logger call that renders an exception traceback.
|
|
|
|
Matches ``logger.exception(...)`` and any ``logger.<level>(...,
|
|
exc_info=<anything>)`` (including ``logger.bind(...).<level>`` chains).
|
|
A literal ``exc_info=False`` is treated as a violation too — it serves
|
|
no purpose and invites a flip to ``True``.
|
|
"""
|
|
method = _logger_method_name(call)
|
|
if method is None:
|
|
return False
|
|
if method == "exception":
|
|
return True
|
|
return any(kw.arg == "exc_info" for kw in call.keywords)
|
|
|
|
|
|
def find_unredacted_traceback_logs(source: str, filename: str) -> list:
|
|
"""Return ``(lineno, function, password_names)`` violations in *source*."""
|
|
tree = ast.parse(source, filename=filename)
|
|
violations = []
|
|
|
|
class Visitor(ast.NodeVisitor):
|
|
def __init__(self):
|
|
self.func_stack = []
|
|
|
|
def _visit_func(self, node):
|
|
self.func_stack.append(node)
|
|
self.generic_visit(node)
|
|
self.func_stack.pop()
|
|
|
|
visit_FunctionDef = _visit_func
|
|
visit_AsyncFunctionDef = _visit_func
|
|
|
|
def visit_Call(self, node):
|
|
if _renders_traceback(node) and self.func_stack:
|
|
func = self.func_stack[-1]
|
|
names = _password_names_in(func)
|
|
if names:
|
|
violations.append((node.lineno, func.name, sorted(names)))
|
|
self.generic_visit(node)
|
|
|
|
Visitor().visit(tree)
|
|
return violations
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"module",
|
|
SWEPT_MODULES,
|
|
ids=lambda m: m.__name__.rsplit(".", 1)[-1],
|
|
)
|
|
def test_no_traceback_log_with_password_in_scope(module):
|
|
"""Every traceback-rendering log call in the swept files must live in
|
|
a function with no password-named variable in scope.
|
|
"""
|
|
path = Path(module.__file__)
|
|
source = path.read_text(encoding="utf-8")
|
|
|
|
violations = find_unredacted_traceback_logs(source, str(path))
|
|
|
|
assert not violations, (
|
|
f"{path.name} has traceback-rendering log calls in functions that "
|
|
f"hold the SQLCipher master password — loguru diagnose=True would "
|
|
f"render it via frame locals. Replace each with "
|
|
f"`safe_msg = redact_secrets(str(e), password)` + "
|
|
f"`logger.warning(...)` (see test_password_leakage.py and PR "
|
|
f"#4530):\n"
|
|
+ "\n".join(
|
|
f" line {lineno}: {func}() — password names in scope: {names}"
|
|
for lineno, func, names in violations
|
|
)
|
|
)
|
|
|
|
|
|
class TestCheckerSelfTest:
|
|
"""Prove the checker actually detects violations — without this, a
|
|
refactor that silently breaks the AST walk would make the invariant
|
|
test pass vacuously.
|
|
"""
|
|
|
|
def test_flags_logger_exception_with_password_param(self):
|
|
src = (
|
|
"def handler(username, password):\n"
|
|
" try:\n"
|
|
" open_db(username, password)\n"
|
|
" except Exception:\n"
|
|
" logger.exception('boom')\n"
|
|
)
|
|
violations = find_unredacted_traceback_logs(src, "<test>")
|
|
assert violations == [(5, "handler", ["password"])]
|
|
|
|
def test_flags_bind_chain_exc_info_with_password(self):
|
|
# The logger.bind(...).debug(..., exc_info=True) form is used live
|
|
# in scheduler/background.py — the checker must follow the chain.
|
|
src = (
|
|
"def handler(username, password):\n"
|
|
" try:\n"
|
|
" open_db(username, password)\n"
|
|
" except Exception:\n"
|
|
" logger.bind(audit=True).debug('boom', exc_info=True)\n"
|
|
)
|
|
violations = find_unredacted_traceback_logs(src, "<test>")
|
|
assert violations == [(5, "handler", ["password"])]
|
|
|
|
def test_flags_bind_chain_exception_with_password(self):
|
|
src = (
|
|
"def handler(username, password):\n"
|
|
" try:\n"
|
|
" open_db(username, password)\n"
|
|
" except Exception:\n"
|
|
" logger.bind(audit=True).exception('boom')\n"
|
|
)
|
|
violations = find_unredacted_traceback_logs(src, "<test>")
|
|
assert violations == [(5, "handler", ["password"])]
|
|
|
|
def test_ignores_aliased_logger(self):
|
|
# Only chains rooted at the literal name ``logger`` are inspected;
|
|
# an aliased logger is a documented blind spot.
|
|
src = (
|
|
"def handler(username, password):\n"
|
|
" try:\n"
|
|
" open_db(username, password)\n"
|
|
" except Exception:\n"
|
|
" log.exception('boom')\n"
|
|
)
|
|
assert find_unredacted_traceback_logs(src, "<test>") == []
|
|
|
|
def test_flags_exc_info_with_password_local(self):
|
|
src = (
|
|
"def handler(username):\n"
|
|
" user_password = store.retrieve(username)\n"
|
|
" try:\n"
|
|
" open_db(username, user_password)\n"
|
|
" except Exception:\n"
|
|
" logger.debug('boom', exc_info=True)\n"
|
|
)
|
|
violations = find_unredacted_traceback_logs(src, "<test>")
|
|
assert violations == [(6, "handler", ["user_password"])]
|
|
|
|
def test_ignores_logger_exception_without_password(self):
|
|
src = (
|
|
"def handler(config):\n"
|
|
" try:\n"
|
|
" reload(config)\n"
|
|
" except Exception:\n"
|
|
" logger.exception('boom')\n"
|
|
)
|
|
assert find_unredacted_traceback_logs(src, "<test>") == []
|
|
|
|
def test_ignores_redacted_warning_with_password(self):
|
|
src = (
|
|
"def handler(username, password):\n"
|
|
" try:\n"
|
|
" open_db(username, password)\n"
|
|
" except Exception as e:\n"
|
|
" safe_msg = redact_secrets(str(e), password)\n"
|
|
" logger.warning(f'boom: {safe_msg}')\n"
|
|
)
|
|
assert find_unredacted_traceback_logs(src, "<test>") == []
|
|
|
|
def test_session_password_store_alone_is_not_a_credential(self):
|
|
src = (
|
|
"def handler(username):\n"
|
|
" session_password_store.touch(username)\n"
|
|
" try:\n"
|
|
" work()\n"
|
|
" except Exception:\n"
|
|
" logger.exception('boom')\n"
|
|
)
|
|
assert find_unredacted_traceback_logs(src, "<test>") == []
|