@@ -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.
0 commit comments