From 52fe7d2102e64af0e19fc74538c4b5adf109fc71 Mon Sep 17 00:00:00 2001 From: Mahmoud Hashemi Date: Fri, 7 Aug 2026 14:26:18 -0700 Subject: [PATCH 1/2] optional value capture with default-on sensitive-key redaction dump_graph/spawn_dump grow values='none'|'builtins'|'repr' (default 'none': behavior and dump format unchanged, one falsy check per object), plus value_limit=128 and redact=True. Captured reprs land in a new object_repr table (object PK, repr, truncated, redacted); meta gains a capture_values column so dumps are self-describing. - tier 'builtins': exact str/bytes/int/float/complex/bool only; C-level repr, never runs user code. str/bytes slice before repr; int guarded against 3.11+ int_max_str_digits; stored text hard-bounded at value_limit. - tier 'repr': everything else via a bounded reprlib.Repr; user __repr__ runs mid-dump (accepted risk, fork isolation via spawn_dump), exceptions skip the row. - redaction post-pass (default on when capturing): any object referenced under a sensitive-looking edge label (key/secret/password/token/auth/..., attr and dict-key edges alike) has its repr replaced by a length-bucketed placeholder '' (buckets 100, 500, 1000, 5000, ...) built from object.len; keys themselves stay readable, one sensitive inbound edge wins for aliased objects. Redaction is dump-time: the artifact never contains the secret text. - Reader.obj_repr (feature-detected via _table_names, old dumps fine), repr in object_summary, display-capped repr snippets in both Reader.object_label and Console._obj_label, Repr row in the web object panel. Known hole inherited from traversal: CPython untracks atomic-only dicts, so their key edges never materialize (values still captured, un-redacted under tier 'repr' container reprs). Tests keep the fixture dict tracked. --- objex/explorer.py | 21 ++++ objex/exporter.py | 162 ++++++++++++++++++++++++++++-- objex/schema.py | 10 +- objex/web.py | 1 + tests/test_value_capture.py | 190 ++++++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 tests/test_value_capture.py diff --git a/objex/explorer.py b/objex/explorer.py index 2f34cb8..c131ca9 100644 --- a/objex/explorer.py +++ b/objex/explorer.py @@ -20,6 +20,9 @@ from .schema import _INDICES from .dbutils import _run_ddl +# display cap for repr snippets appended to object labels +_LABEL_REPR_LIMIT = 40 + class InvalidDatabaseError(ValueError): pass @@ -1040,6 +1043,10 @@ def object_label(self, obj_id): name = 'frame ' + self.frame_codename(obj_id) else: name = self.obj_typequalname(obj_id) + value = self.obj_repr(obj_id) + if value is not None: + name = '{} {}'.format( + name, value if len(value) <= _LABEL_REPR_LIMIT else value[:_LABEL_REPR_LIMIT - 1] + '…') return '<{}{}#{}>'.format(name, mark_label, obj_id) def obj_attributed_size(self, obj_id): @@ -1051,6 +1058,15 @@ def obj_attributed_size(self, obj_id): default=self.obj_size(obj_id), ) + def obj_repr(self, obj_id): + if 'object_repr' not in self._table_names: + return None + return self.sql_val( + 'SELECT repr FROM object_repr WHERE object = ?', + (obj_id,), + default=None, + ) + def object_summary(self, obj_id): refcount = self.obj_refcount(obj_id) size = self.obj_size(obj_id) @@ -1066,6 +1082,7 @@ def object_summary(self, obj_id): 'refcount': refcount, 'refcount_display': self.format_refcount(refcount), 'len': self.obj_len(obj_id), + 'repr': self.obj_repr(obj_id), 'marks': self.get_marks(obj_id), 'flags': { 'is_type': bool(self.obj_is_type(obj_id)), @@ -1380,6 +1397,10 @@ def _obj_label(self, obj_id): name = "frame " + self.reader.frame_codename(obj_id) else: name = self.reader.obj_typequalname(obj_id) + value = self.reader.obj_repr(obj_id) + if value is not None: + name = "{} {}".format( + name, value if len(value) <= _LABEL_REPR_LIMIT else value[:_LABEL_REPR_LIMIT - 1] + '…') coloring = lambda s: colored(s, 'red') return coloring("<{}{}#{}>".format(name, mark_label, obj_id)) diff --git a/objex/exporter.py b/objex/exporter.py index 79543ff..0a89814 100644 --- a/objex/exporter.py +++ b/objex/exporter.py @@ -6,6 +6,7 @@ import resource except ImportError: # windows resource = None +import reprlib import shutil import sys from socket import getfqdn @@ -87,9 +88,17 @@ class _Writer: _TRACKED_TYPES = (types.ModuleType, types.FrameType, types.FunctionType, types.CodeType) _COMMIT_INTERVAL_OBJECTS = 10000 - def __init__(self, conn, use_gc=False): + def __init__(self, conn, use_gc=False, values='none', value_limit=128, redact=True): + if values not in ('none', 'builtins', 'repr'): + raise ValueError("values must be one of 'none', 'builtins', 'repr'") self.conn = conn self.use_gc = use_gc + self.values = values + self.value_limit = value_limit + self.redact = redact + self._reprlib = reprlib.Repr() + self._reprlib.maxstring = value_limit + self._reprlib.maxother = value_limit gc.collect() # try to minimize garbage # track these to separate out "extra" objects that are generated as part of # the object walking process @@ -124,7 +133,7 @@ def __init__(self, conn, use_gc=False): # self.times = [] @classmethod - def write_to_path(cls, path, use_gc=False, use_wal=True): + def write_to_path(cls, path, use_gc=False, use_wal=True, values='none', value_limit=128, redact=True): '''create a new instance that will dump state to path (which shouldn't exist)''' conn = sqlite3.connect(path) conn.text_factory = str @@ -136,12 +145,14 @@ def write_to_path(cls, path, use_gc=False, use_wal=True): num_collected = _gc_prep() conn.execute( """ - INSERT INTO meta (id, pid, hostname, memory_mb, gc_info, num_gcd_objects) - VALUES (0, ?, ?, ?, ?, ?) + INSERT INTO meta (id, pid, hostname, memory_mb, gc_info, num_gcd_objects, capture_values) + VALUES (0, ?, ?, ?, ?, ?, ?) """, - (os.getpid(), getfqdn(), memory, '[{},{},{}]'.format(*gc.get_count()), num_collected)) - writer = cls(conn, use_gc=use_gc) + (os.getpid(), getfqdn(), memory, '[{},{},{}]'.format(*gc.get_count()), num_collected, values)) + writer = cls(conn, use_gc=use_gc, values=values, value_limit=value_limit, redact=redact) writer.add_all() + if writer.values != 'none' and writer.redact: + writer.redact_sensitive_values() writer.finish() except Exception: conn.close() @@ -199,6 +210,8 @@ def _ensure_db_id(self, obj, is_type=False, refs=0): in_gc_objects, is_gc_tracked) ) + if self.values != 'none': + self._maybe_capture_value(obj, obj_id) # all of these are pretty rare (maybe optimize?) if id(obj) not in self.type_id_map and (is_type or isinstance(obj, type)): obj_type_id = self.type_id_map[id(obj)] = len(self.type_id_map) @@ -216,6 +229,50 @@ def _ensure_db_id(self, obj, is_type=False, refs=0): self._handle_tracked_type(obj, obj_id) return obj_id + def _maybe_capture_value(self, obj, obj_id): + ''' + capture a bounded repr of obj into object_repr, per self.values tier. + + tier 'builtins' only touches exact builtin scalar types (C-level + repr, never runs user code); tier 'repr' additionally runs + reprlib.repr on everything else, which EXECUTES user __repr__ + mid-dump -- accepted risk; fork isolation (spawn_dump) bounds the + blast radius to dump quality. + ''' + limit = self.value_limit + obj_type = type(obj) + truncated = False + if obj_type in _VALUE_TYPES: + if obj_type is str or obj_type is bytes: + # slice BEFORE repr to bound cost on huge strings; repr adds + # quotes/escapes, so bound the text itself too + text = repr(obj[:limit]) + truncated = len(obj) > limit or len(text) > limit + text = text[:limit] + elif obj_type is int: + try: + text = repr(obj) + except ValueError: # 3.11+ int_max_str_digits + text = ''.format(obj.bit_length()) + truncated = len(text) > limit + text = text[:limit] + else: # float, complex, bool + text = repr(obj) + elif self.values == 'repr': + try: + text = self._reprlib.repr(obj) + except Exception: # user __repr__ may raise; skip, never fail the dump + return + truncated = len(text) > limit + text = text[:limit] + else: + return + # lone surrogates break sqlite's UTF-8 encode + text = text.encode('utf-8', 'backslashreplace').decode('utf-8') + self.execute( + "INSERT INTO object_repr (object, repr, truncated, redacted) VALUES (?, ?, ?, 0)", + (obj_id, text, int(truncated))) + def _handle_tracked_type(self, obj, obj_id): ''' after creating the row in the object table, handle creating another @@ -554,6 +611,60 @@ def add_all(self): (db_id, self._ensure_db_id(referent, refs=1))) self.ignore_ids.remove(id(sys._getframe())) + def redact_sensitive_values(self): + ''' + replace object_repr rows for values sitting under sensitive-looking + edge labels with a length-bucketed placeholder. key objects (the + string 'password') stay readable -- the key is the clue, the value + is the secret. an object aliased under both a sensitive and an + innocent label is redacted: one sensitive inbound edge wins. + ''' + sensitive_dsts = set() + key_dsts = [] # (key_obj_id, dst) pairs from dict-key edges + for dst, ref in self.conn.execute("SELECT dst, ref FROM reference"): + if not isinstance(ref, str): # list-index edges from enumerate + continue + if ref.startswith('@'): # dict-key edge, format '@' + key_dsts.append((int(ref[1:]), dst)) + continue + ref_lower = ref.lower() + if any(p in ref_lower for p in _SENSITIVE_KEY_PATTERNS): + sensitive_dsts.add(dst) + # dict keys: a key whose captured repr text matches the patterns + # marks its dst sensitive; keys with no captured value can't match + key_text = {} + key_ids = list({key_obj_id for key_obj_id, _ in key_dsts}) + for i in range(0, len(key_ids), 800): + chunk = key_ids[i:i + 800] + qs = ', '.join(['?'] * len(chunk)) + for obj, text in self.conn.execute( + "SELECT object, repr FROM object_repr WHERE object IN ({})".format(qs), chunk): + key_text[obj] = text.lower() + for key_obj_id, dst in key_dsts: + text = key_text.get(key_obj_id) + if text is not None and any(p in text for p in _SENSITIVE_KEY_PATTERNS): + sensitive_dsts.add(dst) + # replace captured rows; length basis is object.len when present + # (true pre-truncation length for str/bytes), else the repr length + updates = [] + dst_ids = list(sensitive_dsts) + for i in range(0, len(dst_ids), 800): + chunk = dst_ids[i:i + 800] + qs = ', '.join(['?'] * len(chunk)) + for obj, text, length in self.conn.execute( + """ + SELECT object_repr.object, object_repr.repr, object.len + FROM object_repr JOIN object ON object_repr.object = object.id + WHERE object_repr.object IN ({}) + """.format(qs), chunk): + n = length if length is not None else len(text) + updates.append((''.format(_len_bucket(n)), obj)) + if updates: + self.executemany( + "UPDATE object_repr SET repr = ?, truncated = 0, redacted = 1 WHERE object = ?", + updates) + self.conn.commit() + def finish(self): self.conn.execute( "UPDATE meta SET duration_s = ?", @@ -577,17 +688,47 @@ def finish(self): _GC_COMPLETE_TYPES = frozenset([ dict, list, tuple, set, frozenset, collections.deque, collections.defaultdict]) +# exact types only: C-level repr, never runs user code (subclasses excluded) +_VALUE_TYPES = (str, bytes, int, float, complex, bool) + +_SENSITIVE_KEY_PATTERNS = ( + # merged from ffcore.redact_sensitive_data defaults and Sentry + # server-side scrubbing defaults + 'key', 'secret', 'passphrase', 'password', 'passwd', 'pwd', + 'token', 'auth', 'credential', 'session', 'private', +) -def dump_graph(path, print_info=False, use_gc=False): + +def _len_bucket(n): + '''smallest bucket >= n from the series 100, 500, 1000, 5000, 10000, ...''' + bucket, step_five = 100, True + while bucket < n: + bucket *= 5 if step_five else 2 + step_five = not step_five + return bucket + + +def dump_graph(path, print_info=False, use_gc=False, values='none', value_limit=128, redact=True): ''' dump a collection db to path; the collection db is designed to be small and write fast, so it needs post-processing to e.g. add indices and compute values before analysis + + values -- value-capture tier: 'none' (default; no object values stored), + 'builtins' (bounded reprs of exact builtin scalars: str, bytes, int, + float, complex, bool), or 'repr' (builtins plus reprlib.repr of + everything else; runs user __repr__ mid-dump -- prefer spawn_dump for + fork isolation) + value_limit -- max stored repr length per object (default 128) + redact -- when capturing values, replace values under sensitive-looking + keys ('secret', 'token', ...) with a length-bucketed placeholder + (default True) ''' start = time.time() - _Writer.write_to_path(path, use_gc=use_gc) + _Writer.write_to_path(path, use_gc=use_gc, values=values, + value_limit=value_limit, redact=redact) if print_info: duration = time.time() - start memory = _get_memory_mb() @@ -609,7 +750,7 @@ def dump_graph(path, print_info=False, use_gc=False): return -def spawn_dump(path, print_info=False, use_gc=False): +def spawn_dump(path, print_info=False, use_gc=False, values='none', value_limit=128, redact=True): if not hasattr(os, 'fork'): raise NotImplementedError('spawn_dump() requires os.fork() support') @@ -618,7 +759,8 @@ def spawn_dump(path, print_info=False, use_gc=False): return pid try: - dump_graph(path, print_info=print_info, use_gc=use_gc) + dump_graph(path, print_info=print_info, use_gc=use_gc, + values=values, value_limit=value_limit, redact=redact) except BaseException: os._exit(1) os._exit(0) diff --git a/objex/schema.py b/objex/schema.py index 5d99b86..3d89f13 100644 --- a/objex/schema.py +++ b/objex/schema.py @@ -14,7 +14,8 @@ memory_mb INTEGER NOT NULL, gc_info TEXT NOT NULL, num_gcd_objects INTEGER NOT NULL, - duration_s REAL + duration_s REAL, + capture_values TEXT -- value-capture tier used for this dump ('none', 'builtins', 'repr') ); CREATE TABLE object ( @@ -27,6 +28,13 @@ is_gc_tracked INTEGER NOT NULL ); +CREATE TABLE object_repr ( + object INTEGER PRIMARY KEY, -- object id + repr TEXT NOT NULL, -- truncated repr text, or redaction placeholder + truncated INTEGER NOT NULL, -- 1 if cut at value_limit + redacted INTEGER NOT NULL -- 1 if replaced by the sensitive-key pass +); + CREATE TABLE pytype ( id INTEGER PRIMARY KEY, object INTEGER NOT NULL, diff --git a/objex/web.py b/objex/web.py index e77cfc3..0165db0 100644 --- a/objex/web.py +++ b/objex/web.py @@ -407,6 +407,7 @@
Size
${obj.size}
Refcount
${escapeHtml(obj.refcount_display ?? String(obj.refcount))}
Len
${obj.len ?? ''}
+ ${obj.repr != null ? `
Repr
${escapeHtml(obj.repr)}
` : ''} `; } diff --git a/tests/test_value_capture.py b/tests/test_value_capture.py new file mode 100644 index 0000000..5fa0308 --- /dev/null +++ b/tests/test_value_capture.py @@ -0,0 +1,190 @@ +''' +end-to-end coverage for optional value capture (values= tiers) and the +default-on sensitive-key redaction pass. dumps are in-process (fast); +assertions go straight at the collection db via sqlite, except the reader +integration test which goes through make_analysis_db + Reader. +''' +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from objex import Reader, dump_graph, make_analysis_db +from objex.exporter import _len_bucket + + +# module-level fixture globals so the dump can see them +SAFE_TEXT = 'objex-visible-marker-' + 'x' * 20 +LEAK = { + 'password': 'hunter2-' * 80, # len 640 -> bucket 1000 + 'safe_note': SAFE_TEXT, # innocent key; value must stay readable + 'api_token': 'tok-' + 'y' * 600, # len 604 -> bucket 1000 + 'count': 424242424242, # unique (non-interned) int + 'audit_trail': [], # tracked value: keeps the dict in the + # gc snapshot so its keys get traversed + # (CPython untracks atomic-only dicts) +} + + +class ReprMarker: + def __repr__(self): + return 'objex-repr-marker-instance' + + +class ExplodingRepr: + def __repr__(self): + raise RuntimeError('never dump me') + + +REPR_INSTANCE = ReprMarker() +EXPLODING_INSTANCE = ExplodingRepr() + + +@pytest.fixture +def tmp_db_dir(): + with tempfile.TemporaryDirectory() as d: + yield Path(d) + + +def _dump(tmp_db_dir, name, **kwargs): + path = tmp_db_dir / name + dump_graph(str(path), **kwargs) + conn = sqlite3.connect(str(path)) + conn.text_factory = str + return path, conn + + +def _key_value_rows(conn, key_repr): + '''object_repr rows of the values referenced under dict key key_repr''' + key_ids = [row[0] for row in conn.execute( + 'SELECT object FROM object_repr WHERE repr = ?', (key_repr,))] + assert key_ids, 'key {} not captured'.format(key_repr) + rows = [] + for key_id in key_ids: + rows.extend(conn.execute( + 'SELECT object_repr.repr, object_repr.redacted, object_repr.truncated' + ' FROM reference JOIN object_repr ON reference.dst = object_repr.object' + ' WHERE reference.ref = ?', ('@{}'.format(key_id),))) + return rows + + +def test_builtins_tier_redacts_sensitive_values(tmp_db_dir): + _, conn = _dump(tmp_db_dir, 'builtins.db', values='builtins') + try: + # innocent value stays readable + safe_rows = conn.execute( + "SELECT redacted FROM object_repr WHERE repr LIKE '%objex-visible-marker%'").fetchall() + assert safe_rows + assert all(redacted == 0 for (redacted,) in safe_rows) + + # key strings themselves stay readable, un-redacted + for key_repr in ("'password'", "'api_token'"): + key_rows = conn.execute( + 'SELECT redacted FROM object_repr WHERE repr = ?', (key_repr,)).fetchall() + assert key_rows, '{} missing'.format(key_repr) + assert all(redacted == 0 for (redacted,) in key_rows) + + # values under sensitive keys are bucketed placeholders + for key_repr in ("'password'", "'api_token'"): + value_rows = _key_value_rows(conn, key_repr) + assert value_rows + for text, redacted, truncated in value_rows: + assert text == '' + assert redacted == 1 + assert truncated == 0 + + # the secret text appears nowhere in the db + assert conn.execute( + "SELECT count(*) FROM object_repr WHERE repr LIKE '%hunter2%'").fetchone()[0] == 0 + assert conn.execute( + "SELECT count(*) FROM object_repr WHERE repr LIKE '%tok-yy%'").fetchone()[0] == 0 + + # innocent int captured verbatim + count_rows = _key_value_rows(conn, "'count'") + assert ('424242424242', 0, 0) in count_rows + + # dump is self-describing + assert conn.execute('SELECT capture_values FROM meta').fetchone()[0] == 'builtins' + finally: + conn.close() + + +def test_default_none_tier_captures_nothing(tmp_db_dir): + _, conn = _dump(tmp_db_dir, 'none.db') + try: + assert conn.execute('SELECT count(*) FROM object_repr').fetchone()[0] == 0 + assert conn.execute('SELECT capture_values FROM meta').fetchone()[0] == 'none' + finally: + conn.close() + + +def test_redact_false_keeps_sensitive_values(tmp_db_dir): + _, conn = _dump(tmp_db_dir, 'noredact.db', values='builtins', redact=False) + try: + assert conn.execute( + "SELECT count(*) FROM object_repr WHERE repr LIKE '%hunter2%'").fetchone()[0] > 0 + assert conn.execute( + 'SELECT count(*) FROM object_repr WHERE redacted = 1').fetchone()[0] == 0 + finally: + conn.close() + + +def test_repr_tier_captures_instances_and_survives_raising_repr(tmp_db_dir): + _, conn = _dump(tmp_db_dir, 'repr.db', values='repr') + try: + assert conn.execute( + "SELECT count(*) FROM object_repr WHERE repr LIKE '%objex-repr-marker-instance%'" + ).fetchone()[0] > 0 + # the ExplodingRepr instance has an object row but no repr row + assert conn.execute( + "SELECT count(*) FROM pytype WHERE name = 'ExplodingRepr'").fetchone()[0] == 1 + finally: + conn.close() + + +def test_value_limit_truncates(tmp_db_dir): + _, conn = _dump(tmp_db_dir, 'trunc.db', values='builtins', value_limit=16) + try: + rows = conn.execute( + "SELECT repr, truncated FROM object_repr WHERE repr LIKE '%objex-visible%'").fetchall() + assert rows + for text, truncated in rows: + assert truncated == 1 + assert len(text) <= 16 + finally: + conn.close() + + +def test_len_bucket(): + assert _len_bucket(0) == 100 + assert _len_bucket(100) == 100 + assert _len_bucket(101) == 500 + assert _len_bucket(640) == 1000 + assert _len_bucket(4999) == 5000 + assert _len_bucket(10001) == 50000 + + +def test_invalid_tier_raises(tmp_db_dir): + with pytest.raises(ValueError): + dump_graph(str(tmp_db_dir / 'bad.db'), values='everything') + + +def test_reader_integration(tmp_db_dir): + dump_path, conn = _dump(tmp_db_dir, 'reader.db', values='builtins') + try: + safe_id = conn.execute( + "SELECT object FROM object_repr WHERE repr LIKE '%objex-visible-marker%'" + ).fetchone()[0] + finally: + conn.close() + analysis_path = tmp_db_dir / 'reader-analysis.db' + make_analysis_db(str(dump_path), str(analysis_path)) + with Reader(str(analysis_path)) as reader: + value = reader.obj_repr(safe_id) + assert 'objex-visible-marker' in value + assert reader.object_summary(safe_id)['repr'] == value + label = reader.object_label(safe_id) + assert 'objex-visible' in label + # display cap: full 43-char repr never lands in the label + assert value not in label From afce064c67ffca738471fa074d844dcab43ef3b1 Mon Sep 17 00:00:00 2001 From: Mahmoud Hashemi Date: Tue, 11 Aug 2026 00:04:50 -0700 Subject: [PATCH 2/2] redaction: protect sensitive-pattern key strings from being redacted as values CPython 3.14 introduced internal dicts that map interned identifier strings to themselves. The redaction pass saw 'api_token' as a value under a key whose repr contains 'token' (sensitive pattern), and replaced the key string's repr with a redacted placeholder. This broke the design invariant: key labels must stay readable so the dump shows *what* was redacted. Fix: after collecting sensitive destinations from dict-key edges, subtract any object ids that are themselves sensitive-pattern keys. These are label strings (like 'password', 'api_token'), never actual secrets. The test now filters to redacted-only rows when checking placeholder format, tolerating the unredacted self-referencing entries. --- objex/exporter.py | 5 +++++ tests/test_value_capture.py | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/objex/exporter.py b/objex/exporter.py index 0a89814..60a09a8 100644 --- a/objex/exporter.py +++ b/objex/exporter.py @@ -640,10 +640,15 @@ def redact_sensitive_values(self): for obj, text in self.conn.execute( "SELECT object, repr FROM object_repr WHERE object IN ({})".format(qs), chunk): key_text[obj] = text.lower() + sensitive_key_obj_ids = set() for key_obj_id, dst in key_dsts: text = key_text.get(key_obj_id) if text is not None and any(p in text for p in _SENSITIVE_KEY_PATTERNS): sensitive_dsts.add(dst) + sensitive_key_obj_ids.add(key_obj_id) + # keys themselves stay readable (even when aliased as a value + # elsewhere, e.g. CPython 3.14 interned-string self-mapping dicts) + sensitive_dsts -= sensitive_key_obj_ids # replace captured rows; length basis is object.len when present # (true pre-truncation length for str/bytes), else the repr length updates = [] diff --git a/tests/test_value_capture.py b/tests/test_value_capture.py index 5fa0308..b3d610a 100644 --- a/tests/test_value_capture.py +++ b/tests/test_value_capture.py @@ -86,12 +86,16 @@ def test_builtins_tier_redacts_sensitive_values(tmp_db_dir): assert all(redacted == 0 for (redacted,) in key_rows) # values under sensitive keys are bucketed placeholders + # (filter to redacted rows: CPython >=3.14 interned-string dicts map + # key strings to themselves, producing a non-redacted value_row where + # the "value" is the key label itself -- those are correctly kept readable) for key_repr in ("'password'", "'api_token'"): value_rows = _key_value_rows(conn, key_repr) assert value_rows - for text, redacted, truncated in value_rows: + redacted_rows = [(t, r, tr) for t, r, tr in value_rows if r == 1] + assert redacted_rows, 'no redacted values for {}'.format(key_repr) + for text, redacted, truncated in redacted_rows: assert text == '' - assert redacted == 1 assert truncated == 0 # the secret text appears nowhere in the db