blancco: CMMC evidence pack, ServiceNow tick list, cloud-upload status

Automates the Blancco parts of the onsite-support CMMC playbook:

- ENT-056/158 evidence pack: /reports/evidence?month=YYYY-MM returns
  ENT-056_<Site>_<YYYY-MM>.zip (summary.csv, README, each report's XML
  and PDF). pxe-blancco-evidence.timer saves last month's pack to the
  reports share's evidence/ folder on the 1st (Persistent).
- ENT-045/159 ServiceNow tick list: /reports/servicenow lists recent
  reports as "Wiped" or "Blancco Wipe failed" with a pre-written
  destruction note, copy buttons and Done (CSRF-protected).
- Cloud upload: blancco-progress.py posts each report's upload result
  ("Sending report document id" -> "Report sent to MC") to
  /reports/cloud-status (CSRF-exempt like /imaging/status); the Reports
  page and the CSVs show it, so a wipe missing from BMP is visible.
- CSV gains failure_reason (exception messages, failed steps, error
  counts), business_location and cloud_upload.
- Preferences: business_location "West Jefferson" (config.BLANCCO_SITE)
  and pdf+xml reports; the report copier now copies the PDF too, named
  after its XML. .part and hidden files are not listed as reports.

State (cloud confirmations, ServiceNow ticks) is a JSON file at
/var/lib/pxe-webapp/blancco-state.json, off the report share.
This commit is contained in:
cproudlock
2026-10-05 13:43:16 -04:00
parent eff956ae6c
commit 44a7eeae95
12 changed files with 590 additions and 26 deletions

View File

@@ -0,0 +1,215 @@
"""CMMC evidence helpers for Blancco erasure reports.
The onsite-support playbook asks techs for, among others:
ENT-045 / 159 list of sanitized media + failure cause (ServiceNow
Onsite Bin RITM "Wiped" / "Blancco Wipe failed" boxes)
ENT-056 / 158 sample Blancco erasure certificates per site, monthly
This module backs the webapp's evidence pack (one ZIP per site and month:
summary.csv plus each report's XML and PDF), the ServiceNow tick list,
and the record of which reports reached the Blancco cloud (posted by
blancco-progress.py running inside Blancco).
State lives in one small JSON file (config.BLANCCO_STATE), not on the report
share, written atomically:
{"cloud": {document_id: {"sent": bool, "ts", "serial", "detail"}},
"servicenow": {document_id: {"ts"}}}
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone
import config
from services import blancco_report
_DOC_ID = re.compile(r"^[0-9a-fA-F-]{36}$")
# --- state ------------------------------------------------------------------
def load_state() -> dict:
try:
with open(config.BLANCCO_STATE) as f:
state = json.load(f)
except (OSError, json.JSONDecodeError):
state = {}
state.setdefault("cloud", {})
state.setdefault("servicenow", {})
return state
def _save_state(state: dict):
folder = os.path.dirname(config.BLANCCO_STATE)
os.makedirs(folder, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".blancco-state-")
with os.fdopen(fd, "w") as f:
json.dump(state, f, indent=1, sort_keys=True)
os.replace(tmp, config.BLANCCO_STATE)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def record_cloud(document_id: str, sent: bool, serial: str = "", detail: str = ""):
if not _DOC_ID.match(document_id or ""):
raise ValueError("bad document_id")
state = load_state()
state["cloud"][document_id] = {
"sent": bool(sent), "ts": _now(),
"serial": (serial or "")[:64], "detail": (detail or "")[:200],
}
_save_state(state)
def mark_servicenow(document_id: str, done: bool = True):
if not _DOC_ID.match(document_id or ""):
raise ValueError("bad document_id")
state = load_state()
if done:
state["servicenow"][document_id] = {"ts": _now()}
else:
state["servicenow"].pop(document_id, None)
_save_state(state)
def cloud_status(state: dict, document_id: str) -> str:
""""sent", "failed", or "" when Blancco never told us."""
rec = state["cloud"].get(document_id)
if not rec:
return ""
return "sent" if rec.get("sent") else "failed"
# --- reports ----------------------------------------------------------------
def site_slug(site: str | None = None) -> str:
return re.sub(r"[^A-Za-z0-9]+", "", site or config.BLANCCO_SITE) or "Site"
def collect(since=None, until=None) -> list[dict]:
"""Every XML report on the share dated in [since, until), oldest first.
Each entry: {"file", "pdf" (or None), "when", "data", "document_id"}.
Dated by the timestamp inside the report, mtime only as a fallback.
"""
folder = config.BLANCCO_REPORTS
names = set(os.listdir(folder)) if os.path.isdir(folder) else set()
out = []
for f in sorted(names):
path = os.path.join(folder, f)
if not f.lower().endswith(".xml") or f.startswith(".") or not os.path.isfile(path):
continue
try:
data = blancco_report.parse(path)
except Exception:
data = {}
when = blancco_report.report_datetime(data) or datetime.fromtimestamp(
os.stat(path).st_mtime, timezone.utc)
if since and when < since:
continue
if until and when >= until:
continue
pdf = os.path.splitext(f)[0] + ".pdf"
out.append({
"file": f,
"pdf": pdf if pdf in names else None,
"when": when,
"data": data,
"document_id": (data.get("meta") or {}).get("document_id", ""),
})
return out
def month_bounds(month: str):
"""'2026-10' -> (2026-10-01T00:00Z, 2026-11-01T00:00Z)."""
y, m = (int(x) for x in month.split("-"))
start = datetime(y, m, 1, tzinfo=timezone.utc)
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=timezone.utc)
return start, end
def previous_month(now=None) -> str:
now = now or datetime.now(timezone.utc)
y, m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12)
return f"{y:04d}-{m:02d}"
def summary_csv(reports: list[dict], state: dict) -> str:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=blancco_report.EXPORT_COLUMNS)
writer.writeheader()
for r in reports:
writer.writerows(blancco_report.export_rows(
r["file"], r["data"], r["when"].isoformat(),
cloud_status(state, r["document_id"])))
return buf.getvalue()
def evidence_name(month: str, site: str | None = None) -> str:
return f"ENT-056_{site_slug(site)}_{month}"
def build_evidence_zip(month: str, fileobj) -> int:
"""Write the evidence pack for `month` into fileobj; return report count."""
start, end = month_bounds(month)
reports = collect(start, end)
state = load_state()
stem = evidence_name(month)
with zipfile.ZipFile(fileobj, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{stem}/summary.csv", summary_csv(reports, state))
zf.writestr(f"{stem}/README.txt", (
f"Blancco erasure evidence, {config.BLANCCO_SITE}, {month} (UTC)\n"
f"{len(reports)} report(s). summary.csv has one row per erased drive,\n"
"with failure_reason for failed wipes and cloud_upload as recorded by\n"
"the PXE server. Certificates of record are in the Blancco Management\n"
"Portal; these are the site copies.\n"))
for r in reports:
zf.write(os.path.join(config.BLANCCO_REPORTS, r["file"]), f"{stem}/{r['file']}")
if r["pdf"]:
zf.write(os.path.join(config.BLANCCO_REPORTS, r["pdf"]), f"{stem}/{r['pdf']}")
return len(reports)
def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
"""Reports to reflect on the Onsite Bin Management RITM, newest first."""
since = datetime.now(timezone.utc) - timedelta(days=days)
state = load_state()
items = []
for r in reversed(collect(since)):
done = state["servicenow"].get(r["document_id"])
if done and not include_done:
continue
data = r["data"]
system = (data.get("hardware") or {}).get("system") or {}
if isinstance(system, list):
system = system[0] if system else {}
erasures = data.get("erasures") or []
failed = [e for e in erasures
if (e.get("state") or "").strip().lower() != "successful"]
reasons = [blancco_report.failure_reason(e) for e in failed]
drives = ", ".join(
f"{(e.get('target') or {}).get('model', '')} {(e.get('target') or {}).get('serial', '')}".strip()
for e in failed)
items.append({
"document_id": r["document_id"],
"file": r["file"],
"when": r["when"],
"serial": system.get("serial", ""),
"model": system.get("model", ""),
"failed": bool(failed) or not erasures,
"note": (f"Blancco wipe failed on {drives or 'drive'}: "
f"{'; '.join(x.rstrip('. ') for x in reasons if x) or 'no erasure recorded'}. "
"Drive to be destroyed; record Certificate of Destruction.")
if (failed or not erasures) else "",
"cloud": cloud_status(state, r["document_id"]),
"done": done.get("ts") if done else None,
})
return items

View File

@@ -125,12 +125,54 @@ EXPORT_COLUMNS = [
"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", "license_consumption_id", "business_name",
"total_errors", "failure_reason", "license_consumption_id",
"business_name", "business_location", "cloud_upload",
]
def export_rows(filename: str, data: dict, when: str) -> list[dict]:
"""One CSV row per erased drive; a report with no erasures still gets a row."""
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):
@@ -145,6 +187,8 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
"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 []:
@@ -167,5 +211,6 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
"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]

View File

@@ -9,6 +9,7 @@ from flask import abort, request, session
# trust-by-network. Keep the list short and explicit.
CSRF_EXEMPT_PATHS = {
"/imaging/status",
"/reports/cloud-status",
}