78 lines
4.5 KiB
Python
78 lines
4.5 KiB
Python
"""Search loaded WhatsApp Desktop message objects via read-only process RAM.
|
|||
|
|
|
||
|
|
Does not invoke WhatsApp's native FTS engine or load older history. The query
|
||
|
|
matches Unicode-normalized, case-insensitive literal text in body/caption.
|
||
|
|
"""
|
||
|
|
from .common import normalized as normalize
|
||
|
|
import argparse, ctypes, datetime as dt, json, sys, unicodedata
|
||
|
|
from pathlib import Path
|
||
|
|
from .ram import Memory, discover, scan_markers, recover
|
||
|
|
|
||
|
|
EVIDENCE='ev_fd70aa06525b6f2259bf6d98bc879adb2472f63f2407a2d0d1186cc9e27b2f21'
|
||
|
|
|
||
|
|
|
||
|
|
def search_models(models,query,chat_id=None,redact=False):
|
||
|
|
needle=normalize(query)
|
||
|
|
if not needle.strip():raise ValueError('Query must contain non-whitespace text')
|
||
|
|
matches=[]
|
||
|
|
for model in models:
|
||
|
|
# Reject schema-only candidates without verified collection membership.
|
||
|
|
if not model.get('in_message_collection'):continue
|
||
|
|
if chat_id is not None and model.get('chat_id')!=chat_id:continue
|
||
|
|
fields=[field for field in ('body','caption') if isinstance(model.get(field),str) and needle in normalize(model[field])]
|
||
|
|
if not fields:continue
|
||
|
|
result=dict(model);result['matched_fields']=fields
|
||
|
|
if redact:
|
||
|
|
for field in ('body','caption'):
|
||
|
|
value=result.pop(field,None)
|
||
|
|
result[field+'_length']=len(value) if isinstance(value,str) else None
|
||
|
|
matches.append(result)
|
||
|
|
return matches
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
p=argparse.ArgumentParser(description=__doc__)
|
||
|
|
p.add_argument('query')
|
||
|
|
p.add_argument('--chat-id')
|
||
|
|
p.add_argument('--max-mb',type=int,default=512)
|
||
|
|
p.add_argument('--max-models',type=int,default=10000)
|
||
|
|
p.add_argument('--redact-text',action='store_true')
|
||
|
|
p.add_argument('--output',type=Path,default=Path('whatsapp-process-search.json'))
|
||
|
|
args=p.parse_args(argv)
|
||
|
|
if sys.platform!='win32' or ctypes.sizeof(ctypes.c_void_p)!=8:p.error('64-bit Windows Python required')
|
||
|
|
if not args.query.strip():p.error('Query must contain non-whitespace text')
|
||
|
|
if not 16<=args.max_mb<=4096:p.error('--max-mb must be 16..4096')
|
||
|
|
if not 1<=args.max_models<=10000:p.error('--max-models must be 1..10000')
|
||
|
|
if args.output.exists():p.error('Output already exists; choose a new --output path')
|
||
|
|
targets=discover();results=[]
|
||
|
|
for target in targets:
|
||
|
|
pid=target['ProcessId']
|
||
|
|
try:mem=Memory(pid)
|
||
|
|
except OSError as error:
|
||
|
|
results.append({'pid':pid,'error':str(error),'matches':[]});continue
|
||
|
|
try:
|
||
|
|
regions=list(mem.regions())
|
||
|
|
markers=scan_markers(mem,regions,args.max_mb)
|
||
|
|
models,diagnostics=recover(mem,regions,markers,args.max_mb,args.max_models)
|
||
|
|
verified=[m for m in models if m.get('in_message_collection')]
|
||
|
|
matches=search_models(verified,args.query,args.chat_id,args.redact_text)
|
||
|
|
results.append({'pid':pid,'loaded_models_examined':len(verified),'matches':matches,
|
||
|
|
'diagnostics':diagnostics,'bytes_read':mem.read_bytes,
|
||
|
|
'model_limit_reached':len(models)>=args.max_models,
|
||
|
|
'debug_flags_present':target['DebugFlagsPresent']})
|
||
|
|
finally:mem.close()
|
||
|
|
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),
|
||
|
|
'mode':'ReadProcessMemory; literal search of collection-verified loaded Msg objects',
|
||
|
|
'whatsapp_native_search_invoked':False,'rea_search_evidence':EVIDENCE,
|
||
|
|
'results':results,'limitations':['Loaded message objects only; not the full IndexedDB or native FTS index.',
|
||
|
|
'No process execution, writes, injection, debugger, UI control, or new history query.',
|
||
|
|
'Live heap may move; the reader checks object layout and collection membership but is not an atomic snapshot.',
|
||
|
|
'Search uses NFC/casefold literal matching; it does not reproduce WhatsApp tokenizer, prefix matching or ranking.',
|
||
|
|
'No match is not evidence of absence from account history.']}
|
||
|
|
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||
|
|
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False),encoding='utf-8')
|
||
|
|
print(json.dumps({'targets':len(results),'loaded_models_examined':sum(r.get('loaded_models_examined',0) for r in results),
|
||
|
|
'matches':sum(len(r['matches']) for r in results),'native_search_invoked':False,
|
||
|
|
'report':str(args.output.resolve())}))
|
||
|
|
return 0 if any(r.get('loaded_models_examined') for r in results) else 2
|
||
|
|
|
||
|
|
if __name__=='__main__':raise SystemExit(main())
|