← back to Unclaimed Property Platform
services/ingestion/naupa3.py
128 lines
"""NAUPA III XML parser adapter — the third ingestion format.
NAUPA III replaces NAUPA II fixed-width with XML validated against an XSD. Real element
names/namespaces vary by jurisdiction version; this is a representative, namespace-TOLERANT
subset sufficient to prove the adapter and its security posture on synthetic data.
Registered lazily by ingest._get_parser under format_name 'naupa3_v1'. Emits the same
CanonicalProperty as the CSV/NAUPA II adapters.
SECURITY — XXE / entity-expansion defense:
Legitimate NAUPA III feeds are XSD-validated DATA documents with NO DTD. We REJECT any
document containing a DOCTYPE/ENTITY declaration before parsing, which closes external-
entity (XXE) and billion-laughs expansion attacks at the door. Production should also use
defusedxml. Python's stdlib ElementTree does not resolve external entities, but a malicious
internal-entity bomb is still worth refusing outright.
"""
from __future__ import annotations
import hashlib
import re
from typing import Iterable
from xml.etree import ElementTree as ET
from services.common.normalize import (
amount_band, mask_name, normalize_business, normalize_postal, normalize_text,
parse_decimal,
)
# Case-insensitive scan for a DTD/entity declaration anywhere in the document prolog/body.
_DTD_RE = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
class XmlSecurityError(ValueError):
"""The XML declared a DTD/ENTITY — refused to protect against XXE / entity expansion."""
def _localname(tag: str) -> str:
return tag.split("}", 1)[-1] if "}" in tag else tag
def _find_text(elem: ET.Element, localname: str) -> str:
"""First descendant whose local (namespace-stripped) name matches; '' if absent."""
for child in elem.iter():
if _localname(child.tag) == localname:
return (child.text or "").strip()
return ""
def _looks_like_business(name: str) -> bool:
from services.common.normalize import CORPORATE_SUFFIXES
return any(tok in CORPORATE_SUFFIXES for tok in normalize_text(name).split())
def build_naupa3_xml(records: list[dict], namespace: str | None = None) -> bytes:
"""Assemble a valid NAUPA-III-style XML document (used by tests/tools).
If `namespace` is given, it's applied as the default xmlns so the parser's
namespace-tolerance is exercised.
"""
ns_attr = f' xmlns="{namespace}"' if namespace else ""
parts = [f"<UnclaimedProperty{ns_attr}>"]
for r in records:
parts.append(" <Property>")
parts.append(f" <PropertyId>{r.get('property_id','')}</PropertyId>")
parts.append(f" <HolderName>{r.get('holder_name','')}</HolderName>")
parts.append(" <Owner>")
parts.append(f" <FirstName>{r.get('first_name','')}</FirstName>")
parts.append(f" <LastName>{r.get('last_name','')}</LastName>")
parts.append(" </Owner>")
parts.append(" <Address>")
parts.append(f" <Street>{r.get('street','')}</Street>")
parts.append(f" <City>{r.get('city','')}</City>")
parts.append(f" <State>{r.get('state','')}</State>")
parts.append(f" <Zip>{r.get('zip','')}</Zip>")
parts.append(" </Address>")
parts.append(f" <PropertyType>{r.get('property_type','')}</PropertyType>")
parts.append(f" <Amount>{r.get('amount','')}</Amount>")
parts.append(" </Property>")
parts.append("</UnclaimedProperty>")
return ("\n".join(parts) + "\n").encode("utf-8")
def parse_naupa3_feed(data: bytes, jurisdiction: str) -> Iterable["object"]:
from services.ingestion.ingest import CanonicalProperty # lazy: avoid import cycle
if _DTD_RE.search(data):
raise XmlSecurityError(
"XML declares a DOCTYPE/ENTITY — refused (XXE / entity-expansion protection)"
)
root = ET.fromstring(data) # no external-entity resolution in stdlib ElementTree
for prop in root.iter():
if _localname(prop.tag) != "Property":
continue
pid = _find_text(prop, "PropertyId")
first = _find_text(prop, "FirstName")
last = _find_text(prop, "LastName")
owner_name = f"{first} {last}".strip()
is_business = _looks_like_business(owner_name)
amount_raw = _find_text(prop, "Amount")
try:
amount = parse_decimal(amount_raw)
except ValueError:
amount = None
owner_norm = (
normalize_business(owner_name) if is_business else normalize_text(owner_name)
)
# canonical raw payload: the serialized element (stable across whitespace/ns)
raw = ET.tostring(prop, encoding="unicode")
raw_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
yield CanonicalProperty(
jurisdiction=jurisdiction,
source_property_id=pid,
holder_name_raw=_find_text(prop, "HolderName"),
owner_type="business" if is_business else "person",
owner_name_raw=owner_name,
owner_name_normalized=owner_norm,
owner_name_masked=mask_name(owner_name),
amount=amount,
amount_band=amount_band(amount),
raw_payload=raw,
raw_record_hash=raw_hash,
city_normalized=normalize_text(_find_text(prop, "City")) or None,
region=normalize_text(_find_text(prop, "State")) or None,
postal_code=normalize_postal(_find_text(prop, "Zip")),
property_type=normalize_text(_find_text(prop, "PropertyType")) or None,
)