""" Parse Blancco Drive Eraser XML report into a friendly Python dict. Blancco encodes everything as V... which is too noisy to read directly in a template. Walking the tree once into nested dicts/lists lets the template render fields by their semantic name without any XPath-style noise. Returned shape (best-effort; missing keys are OK in templates): { "meta": {document_id, date, product_name, product_version, product_revision, integrity}, "company": {business_location, business_name, customer_license, ...}, "license_consumption_ids": [str, ...], "erasures": [ {erasure_id, timestamp, target:{...}, state, elapsed_time, erasure_standard_name, step:[...], ...} ], "hardware": {system:{...}, bios:{...}, processors:{...}, ...}, } """ from __future__ import annotations import xml.etree.ElementTree as ET def _walk(node): # Recursive walk over Blancco's children. # Repeated names collapse to a list. result = {} for child in node: name = child.attrib.get("name") or child.tag if child.tag == "entry": val = (child.text or "").strip() elif child.tag == "entries": val = _walk(child) else: continue if name in result: existing = result[name] if not isinstance(existing, list): result[name] = [existing] result[name].append(val) else: result[name] = val return result def _text(parent, path, default=""): if parent is None: return default el = parent.find(path) if el is None or el.text is None: return default return el.text.strip() def parse(path: str) -> dict: tree = ET.parse(path) root = tree.getroot() rep = root.find(".//report/blancco_data") or root out = { "meta": {}, "company": {}, "license_consumption_ids": [], "erasures": [], "hardware": {}, } desc = rep.find("description") if desc is not None: out["meta"]["document_id"] = _text(desc, "document_id") log_entry = desc.find(".//log_entry") if log_entry is not None: author = log_entry.find("author") if author is not None: out["meta"]["product_name"] = _text(author, "product_name") out["meta"]["product_version"] = _text(author, "product_version") out["meta"]["product_revision"] = _text(author, "product_revision") out["meta"]["date"] = _text(log_entry, "date") out["meta"]["integrity"] = _text(log_entry, "integrity") company = desc.find(".//entry[@name='description_entries']/entries[@name='company_information']") if company is not None: for e in company.findall("entry"): k = e.attrib.get("name", "") if k: out["company"][k] = (e.text or "").strip() lcids = desc.find(".//entry[@name='description_entries']/entries[@name='license_consumption_ids']") if lcids is not None: for e in lcids.findall("entry"): v = (e.text or "").strip() if v: out["license_consumption_ids"].append(v) for er in rep.findall(".//blancco_erasure_report//entries[@name='erasure']"): out["erasures"].append(_walk(er)) hw = rep.find("blancco_erasure_report/../blancco_hardware_report") or rep.find(".//blancco_hardware_report") if hw is not None: for sub in hw: if sub.tag != "entries": continue out["hardware"][sub.attrib.get("name", "")] = _walk(sub) return out def report_datetime(data: dict): """Report creation time from the report itself, or None if missing. Blancco writes ISO 8601 with a numeric offset, e.g. 2026-09-04T17:00:22+0000. File mtime is not reliable for audit ranges: copying reports onto the share resets it. """ from datetime import datetime raw = (data.get("meta") or {}).get("date") or "" try: return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S%z") except ValueError: return None EXPORT_COLUMNS = [ "report_file", "report_date", "document_id", "product_version", "system_manufacturer", "system_model", "system_serial", "drive_model", "drive_serial", "drive_capacity_gb", "drive_interface", "erasure_standard", "state", "start_time", "end_time", "elapsed_time", "total_errors", "failure_reason", "license_consumption_id", "business_name", "business_location", "cloud_upload", ] def _as_list(x): if x is None or x == "": return [] return x if isinstance(x, list) else [x] def failure_reason(er: dict) -> str: """Why an erasure did not succeed, from the report itself; "" if it did. Blancco records exception messages under erasure_details (e.g. "NBP area content was not erased."), a state and error count per step, and total/read/mismatch error counts. Auditors (ENT-158/159) want the cause of failure next to each failed wipe. """ if (er.get("state") or "").strip().lower() == "successful": return "" parts = [] details = er.get("erasure_details") or {} for exc in _as_list(details.get("exception")) + _as_list(details.get("messages")): msg = exc.get("message") if isinstance(exc, dict) else exc if msg and msg not in parts: parts.append(msg) steps = (er.get("steps") or {}).get("step") for i, st in enumerate(_as_list(steps), 1): if isinstance(st, dict) and (st.get("state") or "completed") != "completed": name = st.get("name") or st.get("step_name") or f"step {i}" parts.append(f"{name}: {st.get('state')}") for key in ("total_errors", "read_errors", "mismatch_errors"): try: n = int(er.get(key) or 0) except ValueError: n = 0 if n: parts.append(f"{key.replace('_', ' ')}: {n}") return "; ".join(parts) or f"state: {er.get('state') or 'unknown'}" def export_rows(filename: str, data: dict, when: str, cloud: str = "") -> list[dict]: """One CSV row per erased drive; a report with no erasures still gets a row. cloud is the upload status recorded by the webapp for this report ("sent", "failed" or "" when unknown). """ meta = data.get("meta") or {} system = (data.get("hardware") or {}).get("system") or {} if isinstance(system, list): system = system[0] if system else {} base = { "report_file": filename, "report_date": when, "document_id": meta.get("document_id", ""), "product_version": meta.get("product_version", ""), "system_manufacturer": system.get("manufacturer", ""), "system_model": system.get("model", ""), "system_serial": system.get("serial", ""), "license_consumption_id": ";".join(data.get("license_consumption_ids") or []), "business_name": (data.get("company") or {}).get("business_name", ""), "business_location": (data.get("company") or {}).get("business_location", ""), "cloud_upload": cloud, } rows = [] for er in data.get("erasures") or []: target = er.get("target") or {} if isinstance(target, list): target = target[0] if target else {} try: cap = f"{int(target.get('capacity', '')) / 1e9:.1f}" except ValueError: cap = "" rows.append({ **base, "drive_model": target.get("model", ""), "drive_serial": target.get("serial", ""), "drive_capacity_gb": cap, "drive_interface": target.get("interface_type", ""), "erasure_standard": er.get("erasure_standard_name", ""), "state": er.get("state", ""), "start_time": er.get("start_time", ""), "end_time": er.get("end_time", ""), "elapsed_time": er.get("elapsed_time", ""), "total_errors": er.get("total_errors", ""), "failure_reason": failure_reason(er), }) return rows or [base]