Files
pxe-server/webapp/services/blancco_evidence.py
cproudlock 2dbaaded44 Webapp: evidence pack date range, US month labels, hardware gap rows
Evidence pack takes ?from=&to= (inclusive, Eastern time) with From/To
pickers and quick ranges; ZIP is named ENT-056_<site>_<from>_to_<to>.
Month keys show as 'September 2026'. Hardware gap rows on the to-do
use the shared one-line grid with chips for the missing pack or BIOS.
2026-10-14 15:08:36 -04:00

370 lines
14 KiB
Python

"""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}$")
# Reports without a usable document id (older or hand-made ones) are tracked
# by file name instead, so they can still be ticked and cleared.
_FILE_KEY = re.compile(r"^file:[A-Za-z0-9._-]{1,200}$")
_REPORT_KEY = re.compile(f"{_DOC_ID.pattern}|{_FILE_KEY.pattern}")
def report_key(document_id: str, filename: str) -> str:
"""The state-file key for one report: its document id, else "file:<name>"."""
if _DOC_ID.fullmatch(document_id or ""):
return document_id
# Keys travel in URLs and must match _FILE_KEY, so odd characters in a
# hand-made report's name ("report (1).xml") become "_".
return "file:" + re.sub(r"[^A-Za-z0-9._-]", "_", filename)[:200]
# --- 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.fullmatch(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)
todo_invalidate()
def mark_servicenow(document_id: str, done: bool = True):
if not _REPORT_KEY.fullmatch(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)
todo_invalidate()
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
unreadable = False
try:
data = blancco_report.parse(path)
except Exception:
data, unreadable = {}, True
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", ""),
"unreadable": unreadable,
})
out[-1]["key"] = report_key(out[-1]["document_id"], f)
return out
def _tz():
from zoneinfo import ZoneInfo
return ZoneInfo(config.DISPLAY_TZ)
def month_bounds(month: str):
"""'2026-10' -> start of that month and of the next, in the site's
display timezone (a wipe at 21:00 EDT on the 31st belongs to that
month, not the next one as it would in UTC)."""
y, m = (int(x) for x in month.split("-"))
start = datetime(y, m, 1, tzinfo=_tz())
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=_tz())
return start, end
def previous_month(now=None) -> str:
now = now or datetime.now(_tz())
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"].astimezone(_tz()).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 evidence_range_name(first, last, site: str | None = None) -> str:
"""File names stay year-first so they sort; the pages show US dates."""
return f"ENT-056_{site_slug(site)}_{first.isoformat()}_to_{last.isoformat()}"
def day_bounds(first, last):
"""date, date -> start of `first` and of the day after `last`, in the
site's display timezone (America/New_York): a whole-day range."""
from datetime import timedelta
start = datetime(first.year, first.month, first.day, tzinfo=_tz())
nxt = last + timedelta(days=1)
return start, datetime(nxt.year, nxt.month, nxt.day, tzinfo=_tz())
def us_date(d) -> str:
"""10/14/2026: the date format the site reads."""
return f"{d.month:02d}/{d.day:02d}/{d.year:04d}"
def range_label(first, last) -> str:
return f"{us_date(first)} to {us_date(last)}"
def build_evidence_zip(month, fileobj, first=None, last=None) -> int:
"""Write an evidence pack into fileobj; return the report count. Either a
calendar month ('2026-09', the monthly pack) or a day range first..last
(dates, inclusive, Eastern time)."""
if first and last:
start, end = day_bounds(first, last)
stem = evidence_range_name(first, last)
period = range_label(first, last)
else:
start, end = month_bounds(month)
stem = evidence_name(month)
period = month
reports = collect(start, end)
state = load_state()
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}, {period} ({config.DISPLAY_TZ})\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 _target(er: dict) -> dict:
"""An erasure's target; parse() makes a repeated target a list."""
t = er.get("target") or {}
if isinstance(t, list):
t = t[0] if t else {}
return t if isinstance(t, dict) else {}
def failure_note(data: dict):
"""(failed erasures, ServiceNow note) for a parsed report. A drive is
failed only if none of its erasures succeeded (a Purge failure followed
by a successful Clear fallback is a good wipe). The note is "" when every
drive was wiped, and also covers a report with no erasure at all."""
erasures = data.get("erasures") or []
outcomes = blancco_report.drive_outcomes(data)
failed = [e for i, e in enumerate(erasures)
if not outcomes[blancco_report._drive_key(e, i)]["ok"]]
if erasures and not failed:
return failed, ""
reasons = [blancco_report.failure_reason(e) for e in failed]
drives = ", ".join(
f"{_target(e).get('model', '')} {_target(e).get('serial', '')}".strip()
for e in failed)
return failed, (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.")
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)):
if r.get("unreadable"):
continue # /reports flags it as Unreadable; nothing to tick or destroy
done = state["servicenow"].get(r["key"])
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, note = failure_note(data)
items.append({
"document_id": r["document_id"],
"key": r["key"],
"file": r["file"],
"when": r["when"].astimezone(_tz()),
"serial": system.get("serial", ""),
"model": system.get("model", ""),
"failed": bool(failed) or not erasures,
"note": note,
"cloud": cloud_status(state, r["document_id"]),
"done": done.get("ts") if done else None,
})
return items
# --- technician to-do -------------------------------------------------------
# The parts of the playbook the server cannot do itself. Each kind is a dict
# in the state file keyed by month or document_id.
TODO_KINDS = {
"evidence_uploaded": re.compile(r"^20\d\d-(0[1-9]|1[0-2])$"), # month
"destroyed": _REPORT_KEY, # failed wipe: Certificate of Destruction recorded
"cloud_resolved": _REPORT_KEY, # report not in the cloud: uploaded by hand
}
_EVIDENCE_ZIP = re.compile(r"^ENT-056_.+_(20\d\d-\d\d)\.zip$")
def mark(kind: str, key: str, done: bool = True):
pattern = TODO_KINDS.get(kind)
if not pattern or not pattern.fullmatch(key or ""):
raise ValueError("bad to-do item")
state = load_state()
bucket = state.setdefault(kind, {})
if done:
bucket[key] = {"ts": _now()}
else:
bucket.pop(key, None)
_save_state(state)
todo_invalidate()
def todo() -> dict:
"""Open technician tasks, newest first in each list."""
state = load_state()
for kind in TODO_KINDS:
state.setdefault(kind, {})
evidence_dir = os.path.join(config.BLANCCO_REPORTS, "evidence")
packs = []
if os.path.isdir(evidence_dir):
for f in sorted(os.listdir(evidence_dir), reverse=True):
m = _EVIDENCE_ZIP.match(f)
if m and m.group(1) not in state["evidence_uploaded"]:
packs.append({"month": m.group(1), "file": f})
year = servicenow_items(days=366, include_done=True)
failed = [i for i in year if i["failed"] and i["key"] not in state["destroyed"]]
not_in_cloud = [i for i in year
if i["cloud"] == "failed" and i["key"] not in state["cloud_resolved"]]
servicenow = sum(1 for i in year if not i["done"]
and i["when"] >= datetime.now(timezone.utc) - timedelta(days=30))
return {
"servicenow": servicenow,
"evidence": packs,
"failed": failed,
"not_in_cloud": not_in_cloud,
"total": servicenow + len(packs) + len(failed) + len(not_in_cloud),
}
_todo_cache = {"at": 0.0, "value": None}
def todo_counts(ttl: float = 60.0) -> dict:
"""todo() reduced to counts, cached: it is shown on every page."""
import time
now = time.monotonic()
if _todo_cache["value"] is None or now - _todo_cache["at"] > ttl:
try:
t = todo()
value = {"servicenow": t["servicenow"], "evidence": len(t["evidence"]),
"failed": len(t["failed"]), "not_in_cloud": len(t["not_in_cloud"]),
"total": t["total"]}
except Exception:
value = {"servicenow": 0, "evidence": 0, "failed": 0, "not_in_cloud": 0, "total": 0}
_todo_cache.update(at=now, value=value)
return _todo_cache["value"]
def todo_invalidate():
_todo_cache["value"] = None