Skip to content

Commit e3b8f7d

Browse files
Merge pull request #5310 from springfall2008/fix/coordinator-warn-once
fix(coordinator): warn only about genuine misfilings, once per record
2 parents 00f388b + 57340bc commit e3b8f7d

2 files changed

Lines changed: 186 additions & 14 deletions

File tree

‎apps/predbat/coordinator.py‎

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,22 @@ class Redactor:
269269
# a false-positive rewrite of unrelated data.
270270
MIN_SUBSTITUTE = 6
271271

272-
def __init__(self, salt, log=None):
273-
"""Hold the installation salt and an optional logger for misfiled values."""
272+
def __init__(self, salt, log=None, warned=None):
273+
"""Hold the installation salt, an optional logger for misfiled values, and the set of
274+
warnings already logged - owned by the caller so a warning is logged once per process
275+
rather than on every redaction (every debug dump and publish)."""
274276
self.salt = salt
275277
self.log = log
278+
self.warned = warned if warned is not None else set()
279+
# Shape-guard hits, held until the walk is done - see _flush_warnings
280+
self._pending_warnings = []
281+
# device_id of the record being walked, so a warning is deduplicated per record rather
282+
# than per field - see _flush_warnings
283+
self._record_device_id = None
284+
# Every variant of an account identifier noted from a pseudonym container or an
285+
# identity-derived device_id: a guard hit wholly explained by one of these is the
286+
# redaction working as designed, not a misfiling, so it is not warned about
287+
self._identity_variants = set()
276288
self.originals = {}
277289
# Subset of self.originals eligible for substring replacement inside a VALUE (never a
278290
# dict key - see _substitute_key) - true identifiers: account_ids values, an
@@ -290,7 +302,7 @@ def token(self, value):
290302
digest = hashlib.sha256((self.salt + str(value)).encode("utf-8")).hexdigest()
291303
return "#" + digest[:8]
292304

293-
def _note(self, value, substring=False):
305+
def _note(self, value, substring=False, identity=False):
294306
"""Record an original so it can later be swapped for its token wherever it appears.
295307
296308
Builds the FULL variant set (see _numeric_variants/_identifier_variants) before minting
@@ -315,7 +327,8 @@ def _note(self, value, substring=False):
315327
replacement inside a VALUE (never a dict key - see _substitute_key); otherwise each is
316328
only ever matched by whole-string equality. Registering a variant here unconditionally,
317329
even one shorter than MIN_SUBSTITUTE, is safe: nothing is ever matched against it below
318-
that floor - see _exact_match/_substitute_text.
330+
that floor - see _exact_match/_substitute_text. identity=True marks the value as an account
331+
identifier reported where one belongs - see _flush_warnings.
319332
"""
320333
text = str(value)
321334
variants = {text} | self._numeric_variants(text)
@@ -327,6 +340,8 @@ def _note(self, value, substring=False):
327340
self.originals[variant] = token
328341
if substring:
329342
self.substring_ok.add(variant)
343+
if identity:
344+
self._identity_variants.add(variant)
330345
return token
331346

332347
def _identifier_variants(self, text):
@@ -498,11 +513,14 @@ def _is_location_key(self, name):
498513

499514
def _guard_scalar(self, container, name, value):
500515
"""Pseudonymise one scalar value if it looks misfiled or sits under a location-named field, logging where it was found."""
501-
if not (self._misfiled(value, strict_numeric=(container == "hardware_ids")) or self._is_location_key(name)):
516+
strict_numeric = container == "hardware_ids"
517+
location = self._is_location_key(name)
518+
if not (location or self._misfiled(value, strict_numeric=strict_numeric)):
502519
return value
503-
if self.log:
504-
label = container if container == name else "{}.{}".format(container, name)
505-
self.log("Warn: Coordinator: {} looks like an identifier in a clear container - pseudonymised".format(label))
520+
label = container if container == name else "{}.{}".format(container, name)
521+
message = "Warn: Coordinator: {} looks like an identifier in a clear container - pseudonymised".format(label)
522+
# A location-named field is flagged by its name, whatever the value, so it is always warned about
523+
self._pending_warnings.append((message, None if location else value, strict_numeric, self._record_device_id))
506524
return self._note(value, substring=True)
507525

508526
def _guard_value(self, container, name, value):
@@ -535,12 +553,66 @@ def _guard_key(self, container, name):
535553
file, exactly what this guard exists to keep out of the debug dump attached to the same
536554
public issue. Matches _guard_scalar, which never logs the raw value it pseudonymises either.
537555
"""
538-
if not self._misfiled(name, strict_numeric=(container == "hardware_ids")):
556+
strict_numeric = container == "hardware_ids"
557+
if not self._misfiled(name, strict_numeric=strict_numeric):
539558
return name
540-
if self.log:
541-
self.log("Warn: Coordinator: {} key looks like an identifier - pseudonymised".format(container))
559+
self._pending_warnings.append(("Warn: Coordinator: {} key looks like an identifier - pseudonymised".format(container), name, strict_numeric, self._record_device_id))
542560
return self._note(name, substring=True)
543561

562+
def _flush_warnings(self):
563+
"""Log the shape-guard hits that are genuinely misfiled values, each at most once per process.
564+
565+
Runs after the walk, once every account identifier in the document has been noted, so a
566+
hit can be judged against all of them whatever order the records were walked in. A value
567+
that stops looking like an identifier once those are removed - Octopus's
568+
"octopus:{mpan}" device_id, a programme's cross-link to it, a station id echoed as a
569+
number - is the redaction working as designed: it is still pseudonymised, but warning
570+
about it on every debug dump is noise, not advice. The message itself never holds the
571+
value (see _guard_key), so it is deduplicated on the message plus the pseudonym of the
572+
record's device_id: a second record misfiling the same field is a new leak and warns
573+
too, while the same record on the next dump does not.
574+
"""
575+
if not self._pending_warnings:
576+
return
577+
identities = [self._whole_token(identity) for identity in sorted(self._identity_variants, key=len, reverse=True)]
578+
for message, value, strict_numeric, device_id in self._pending_warnings:
579+
if value is not None and self._explained_by_identity(value, identities, strict_numeric):
580+
continue
581+
key = (message, None if device_id is None else self.token(device_id))
582+
if key in self.warned:
583+
continue
584+
self.warned.add(key)
585+
if self.log:
586+
self.log(message)
587+
self._pending_warnings = []
588+
589+
def _explained_by_identity(self, value, identities, strict_numeric):
590+
"""Whether a shape-guard hit is wholly an account identifier reported where one belongs.
591+
592+
`identities` are whole-token patterns (see _whole_token), so a short identifier never
593+
splits a longer number's digit run and hides a genuine misfiling. A number is judged by
594+
its integer part, as _misfiled does, and is only explained when that IS an identifier -
595+
a numeric echo - since stripping digits out of it would rewrite it into something else.
596+
"""
597+
if isinstance(value, bool):
598+
return False
599+
if isinstance(value, (int, float)):
600+
try:
601+
text = str(abs(int(value)))
602+
except (ValueError, OverflowError):
603+
return False
604+
return any(identity.fullmatch(text) for identity in identities)
605+
# Replaced by a non-digit, non-separator placeholder, not removed: removing it would join
606+
# the digit runs either side of it into one that looks like an identifier
607+
remaining = str(value)
608+
for identity in identities:
609+
remaining = identity.sub("|", remaining)
610+
return not self._misfiled(remaining, strict_numeric=strict_numeric)
611+
612+
def _embeds_account_id(self, record):
613+
"""Whether a record's device_id carries one of its own account identifiers as a whole token."""
614+
return any(self._whole_token(value).search(record["device_id"]) for value in self._pseudonym_values(record) if value)
615+
544616
def _has_pseudonym_container(self, node):
545617
"""Whether a pseudonym container (account_ids) sits anywhere in this record - the record
546618
itself, or a nested sub-record (a meter's tariff, say) - not only its own top-level keys.
@@ -581,11 +653,14 @@ def _walk(self, node, container=None):
581653
"""
582654
if isinstance(node, dict):
583655
if isinstance(node.get("device_id"), str) and self._has_pseudonym_container(node) and not self._serial_derived(node):
584-
self._note(node["device_id"], substring=True)
656+
self._note(node["device_id"], substring=True, identity=self._embeds_account_id(node))
657+
outer_device_id = self._record_device_id
658+
if isinstance(node.get("device_id"), str):
659+
self._record_device_id = node["device_id"]
585660
out = {}
586661
for key, value in node.items():
587662
if key in PSEUDONYM_CONTAINERS:
588-
out[key] = {self._guard_key(key, name): self._note(entry, substring=True) for name, entry in value.items()}
663+
out[key] = {self._guard_key(key, name): self._note(entry, substring=True, identity=True) for name, entry in value.items()}
589664
elif key in CLEAR_CONTAINERS and isinstance(value, dict):
590665
out[key] = {self._guard_key(key, name): self._guard_value(key, name, entry) for name, entry in value.items()}
591666
elif key in VOCAB_CONTAINERS:
@@ -594,6 +669,7 @@ def _walk(self, node, container=None):
594669
out[key] = [self._guard_scalar(key, "token", entry) for entry in value]
595670
else:
596671
out[key] = self._walk(value, container=key)
672+
self._record_device_id = outer_device_id
597673
return out
598674
if isinstance(node, list):
599675
return [self._walk(entry, container=container) for entry in node]
@@ -697,6 +773,7 @@ def redact(self, catalogue):
697773
serials = sorted(self._collect_serials(catalogue), key=len, reverse=True)
698774
self._serial_patterns = [self._whole_token(serial) for serial in serials]
699775
walked = self._walk(catalogue)
776+
self._flush_warnings()
700777
self._substring_order = sorted(self.substring_ok, key=len, reverse=True)
701778
substituted = self._substitute(walked)
702779
if "generated" in catalogue:
@@ -728,6 +805,8 @@ def __init__(self, base):
728805
# the original startup-barrier assemble() call).
729806
self.assembled = None
730807
self.salt = None
808+
# Redaction warnings already logged - see Redactor._flush_warnings
809+
self.redaction_warned = set()
731810

732811
def report(self, component_name, report):
733812
"""Validate and store one component's discovery report, replacing any previous one."""
@@ -905,7 +984,7 @@ def catalogue(self):
905984
dicts under a lock briefly held to copy self.reports, so redoing it on every debug dump or
906985
publish() call - its only two callers - costs milliseconds, not a measurable resource.
907986
"""
908-
return Redactor(self.load_salt(), log=self.log).redact(self.assemble())
987+
return Redactor(self.load_salt(), log=self.log, warned=self.redaction_warned).redact(self.assemble())
909988

910989
def catalogue_raw(self):
911990
"""The assembled catalogue, unredacted. In-process diagnostics only - never write this anywhere.

‎apps/predbat/tests/test_coordinator.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,12 +1480,101 @@ def test_guard_key_does_not_log_the_raw_value():
14801480
messages = []
14811481
redactor = Redactor("test-salt-0001", log=messages.append)
14821482
redactor._guard_key("hardware_ids", "1234567890123")
1483+
redactor._flush_warnings()
14831484
assert any("hardware_ids" in message for message in messages), "expected a warning naming the container: {}".format(messages)
14841485
assert not any("1234567890123" in message for message in messages), "the raw identifier must never reach the log: {}".format(messages)
14851486
print("PASS: _guard_key logs only the container, never the raw key text it is hiding")
14861487
return 0
14871488

14881489

1490+
def test_identity_derived_values_do_not_warn():
1491+
"""Octopus reports each meter as "octopus:{mpan}" with the MPAN in account_ids, and a programme
1492+
cross-links to it. That is the redaction working as designed - every one of those is still
1493+
pseudonymised - so it must not log a warning on every debug dump, whichever record is walked first."""
1494+
base, coordinator = _redacting_coordinator()
1495+
messages = []
1496+
coordinator.log = messages.append
1497+
coordinator.report(
1498+
"octopus",
1499+
{
1500+
"programmes": [{"device_id": "axle:site1", "kind": "vpp", "meter": "octopus:1234567890123"}],
1501+
"meters": [{"device_id": "octopus:1234567890123", "direction": "import", "account_ids": {"mpan": "1234567890123", "station": 61234567890}}],
1502+
},
1503+
)
1504+
coordinator.report("solis", {"inverters": [{"device_id": "solis:x1", "ratings": {"echo": 61234567890}}]})
1505+
catalogue = coordinator.catalogue()
1506+
text = str(catalogue)
1507+
assert "1234567890123" not in text and "61234567890" not in text, text
1508+
assert not [message for message in messages if "identifier" in message], messages
1509+
print("PASS: an identifier redacted where it belongs, and its echoes, are not warned about")
1510+
return 0
1511+
1512+
1513+
def test_misfiled_identifier_warns_once_per_process():
1514+
"""A genuine misfiling warns, but once - not again on every later debug dump or publish - and an
1515+
account identifier alongside it does not hide an unrelated identifier sharing the same value."""
1516+
base, coordinator = _redacting_coordinator()
1517+
messages = []
1518+
coordinator.log = messages.append
1519+
coordinator.report(
1520+
"octopus",
1521+
{"meters": [{"device_id": "octopus:1234567890123", "direction": "import", "account_ids": {"mpan": "1234567890123"}, "info": {"note": "1234567890123 and 9876543210987"}}]},
1522+
)
1523+
for _ in range(3):
1524+
catalogue = coordinator.catalogue()
1525+
assert "9876543210987" not in str(catalogue)
1526+
warnings = [message for message in messages if "identifier" in message]
1527+
assert warnings == ["Warn: Coordinator: info.note looks like an identifier in a clear container - pseudonymised"], warnings
1528+
print("PASS: a genuine misfiling warns exactly once across repeated redactions")
1529+
return 0
1530+
1531+
1532+
def test_short_or_unrelated_identities_do_not_hide_misfiled_values():
1533+
"""Suppression is for a value wholly explained by an account identifier: a short identifier
1534+
must not split a longer number's digit run, a device_id that embeds no account identifier is
1535+
not identity-derived, and a numeric identifier is judged by its integer part."""
1536+
messages = []
1537+
redactor = Redactor("test-salt-0001", log=messages.append)
1538+
redactor.redact(
1539+
{
1540+
"meters": [
1541+
{"device_id": "octopus:9876543210987", "direction": "import", "account_ids": {"site": "1"}, "info": {"long": "1213141516171"}},
1542+
{"device_id": "octopus:5", "direction": "import", "account_ids": {"site": "1"}, "info": {"big": 1213141516171}},
1543+
]
1544+
}
1545+
)
1546+
warnings = sorted(message for message in messages if "identifier" in message)
1547+
assert any("info.long" in message for message in warnings), warnings
1548+
assert any("info.big" in message for message in warnings), warnings
1549+
assert any("device_id" in message for message in warnings), warnings
1550+
print("PASS: short or unrelated identities do not hide genuinely misfiled values")
1551+
return 0
1552+
1553+
1554+
def test_warning_is_once_per_record_and_ignores_joined_digit_runs():
1555+
"""A second record misfiling the same field is a new leak and warns too (once per record, not
1556+
once per field); and removing an explained identifier must not join the digit runs either side
1557+
of it into one that looks like an identifier."""
1558+
base, coordinator = _redacting_coordinator()
1559+
messages = []
1560+
coordinator.log = messages.append
1561+
1562+
def meter(device_id, mpan, note):
1563+
"""One Octopus-shaped meter record with a note in its clear info container."""
1564+
return {"device_id": device_id, "direction": "import", "account_ids": {"mpan": mpan}, "info": {"note": note}}
1565+
1566+
coordinator.report(
1567+
"octopus",
1568+
{"meters": [meter("octopus:1111111111111", "1111111111111", "9876543210987"), meter("octopus:2222222222222", "2222222222222", "9876543210988"), meter("octopus:3333333333333", "3333333333333", "ref 12345 3333333333333 67890")]},
1569+
)
1570+
for _ in range(2):
1571+
coordinator.catalogue()
1572+
warnings = [message for message in messages if "info.note" in message]
1573+
assert len(warnings) == 2, warnings
1574+
print("PASS: a warning is once per record, and an explained identifier does not join digit runs")
1575+
return 0
1576+
1577+
14891578
# --- Review round 3: Task 7 review - a case/separator-transformed echo of a pseudonymised value ---
14901579

14911580

@@ -2011,6 +2100,10 @@ def test_coordinator_all(my_predbat=None):
20112100
failures += test_shorter_original_does_not_fragment_a_longer_one()
20122101
failures += test_misfiled_identifier_used_as_a_container_key_caught()
20132102
failures += test_guard_key_does_not_log_the_raw_value()
2103+
failures += test_identity_derived_values_do_not_warn()
2104+
failures += test_misfiled_identifier_warns_once_per_process()
2105+
failures += test_short_or_unrelated_identities_do_not_hide_misfiled_values()
2106+
failures += test_warning_is_once_per_record_and_ignores_joined_digit_runs()
20142107
failures += test_identifier_variants_registered_for_case_and_separator_transforms()
20152108
failures += test_identifier_variants_fold_up_as_well_as_down()
20162109
failures += test_pseudonymised_value_hidden_when_case_folded_and_separator_swapped_in_entity_id()

0 commit comments

Comments
 (0)