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