From 44a7eeae95998793a54792bbbd700aaf2eda1833 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 5 Oct 2026 13:43:16 -0400 Subject: [PATCH] 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__.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. --- playbook/blancco-init.sh | 32 ++-- playbook/blancco-preferences.xml | 6 +- playbook/blancco-progress.py | 31 ++++ playbook/pxe_server_setup.yml | 38 ++++ webapp/app.py | 74 +++++++- webapp/blancco_evidence_monthly.py | 36 ++++ webapp/config.py | 5 + webapp/services/blancco_evidence.py | 215 +++++++++++++++++++++++ webapp/services/blancco_report.py | 51 +++++- webapp/services/csrf.py | 1 + webapp/templates/reports.html | 23 +++ webapp/templates/reports_servicenow.html | 104 +++++++++++ 12 files changed, 590 insertions(+), 26 deletions(-) create mode 100644 webapp/blancco_evidence_monthly.py create mode 100644 webapp/services/blancco_evidence.py create mode 100644 webapp/templates/reports_servicenow.html diff --git a/playbook/blancco-init.sh b/playbook/blancco-init.sh index cfc36b8..15be4d8 100644 --- a/playbook/blancco-init.sh +++ b/playbook/blancco-init.sh @@ -291,33 +291,39 @@ cat > /run/newroot/usr/local/sbin/blancco-report-sync << 'XEOF' #!/bin/bash # Copy Blancco reports to the SMB report share. # -# In process=auto Albus saves each report to /reports/.xml and -# uploads it to the cloud, but never writes the share: auto_backup is -# forced off outside workflow/manual mode. Albus has the share mounted at -# /mnt/share already, so copy each finished report there, named the way a -# manual Save names it (UTC YYYYMMDD_HHMMSS_report.xml). +# In process=auto Albus saves each report to /reports/.xml +# (and .pdf when pdf+xml is selected) and uploads it to the cloud, but never +# writes the share: auto_backup is forced off outside workflow/manual mode. +# Albus has the share mounted at /mnt/share already, so copy each finished +# file there, named the way a manual Save names it (UTC +# YYYYMMDD_HHMMSS_report., the time taken from the XML so the PDF of +# the same report gets the same name). # Usage: blancco-report-sync [--once] DONE=/var/lib/blancco-report-sync mkdir -p "$DONE" while :; do - for f in /reports/*.xml; do + for f in /reports/*.xml /reports/*.pdf; do [ -s "$f" ] || continue - id=$(basename "$f" .xml) - [ -e "$DONE/$id" ] && continue + base=$(basename "$f") + [ -e "$DONE/$base" ] && continue # Leave files Albus may still be writing. [ $(( $(date +%s) - $(stat -c %Y "$f") )) -ge 5 ] || continue mountpoint -q /mnt/share || continue - name="$(date -u -r "$f" +%Y%m%d_%H%M%S)_report.xml" + id=${base%.*} + ext=${base##*.} + ref="/reports/$id.xml" + [ -f "$ref" ] || ref="$f" + name="$(date -u -r "$ref" +%Y%m%d_%H%M%S)_report.$ext" if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then - touch "$DONE/$id" - echo "copied $id to $name" - serial=$(tr -d '"\\' < /sys/class/dmi/id/product_serial 2>/dev/null) + touch "$DONE/$base" + echo "copied $base to $name" + serial=$(tr -d '"\\' 2>/dev/null < /sys/class/dmi/id/product_serial) [ -n "$serial" ] && curl -s -m 5 -o /dev/null -H 'Content-Type: application/json' \ -d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \ http://172.16.9.1:9009/imaging/status || true else rm -f "/mnt/share/$name.part" - echo "copy of $id failed, will retry" + echo "copy of $base failed, will retry" fi done [ "$1" = "--once" ] && exit 0 diff --git a/playbook/blancco-preferences.xml b/playbook/blancco-preferences.xml index 51b5c2f..852f6a4 100644 --- a/playbook/blancco-preferences.xml +++ b/playbook/blancco-preferences.xml @@ -252,7 +252,7 @@ 2026-06-22 - + West Jefferson GE Aerospace @@ -312,8 +312,8 @@ false true - xml - pdf+xml + xml + pdf+xml 0 true diff --git a/playbook/blancco-progress.py b/playbook/blancco-progress.py index 9faaf58..9d5457f 100644 --- a/playbook/blancco-progress.py +++ b/playbook/blancco-progress.py @@ -21,6 +21,7 @@ import time import urllib.request STATUS_URL = "http://172.16.9.1:9009/imaging/status" +CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status" STAGE_TOTAL = 7 # Progress lines arrive in bursts; post at most this often unless the # stage itself changes. @@ -69,6 +70,11 @@ RULES = [ (re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None), (re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"), ] +# Cloud upload of a report: "[comm.manager] Sending report document id: " +# then "Report sent to MC" (or an upload failure). +SENDING = re.compile(r"Sending report document id: ([0-9a-fA-F-]{36})") +SENT = re.compile(r"Report sent to MC") +SEND_FAILED = re.compile(r"(report (upload|send|sending)[^\n]*fail|failed to (send|upload) report)", re.I) # Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test # completion and volume lines that also carry a percentage. PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I) @@ -115,10 +121,35 @@ class Reporter: self.pending = [] self.last_post = now + def cloud(self, document_id, sent, detail): + """Tell the webapp whether a report reached the Blancco cloud.""" + payload = {"document_id": document_id, "serial": SERIAL, + "sent": sent, "detail": detail[:200]} + if self.dry_run: + print(json.dumps({"cloud": payload})) + return + req = urllib.request.Request( + CLOUD_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}) + try: + urllib.request.urlopen(req, timeout=5).read() + except OSError: + pass + def line(self, text): text = text.strip() if not text: return + m = SENDING.search(text) + if m: + self.sending = m.group(1) + elif getattr(self, "sending", None): + if SENT.search(text): + self.cloud(self.sending, True, text) + self.sending = None + elif SEND_FAILED.search(text): + self.cloud(self.sending, False, text) + self.sending = None for pat, idx, stage, status in RULES: m = pat.search(text) if not m: diff --git a/playbook/pxe_server_setup.yml b/playbook/pxe_server_setup.yml index bd413b5..77136a5 100644 --- a/playbook/pxe_server_setup.yml +++ b/playbook/pxe_server_setup.yml @@ -1148,6 +1148,44 @@ enabled: yes daemon_reload: yes + # Monthly Blancco CMMC evidence pack (ENT-056/158): on the 1st, save last + # month's ZIP to the reports share's evidence/ folder for the tech to copy + # into Box. Persistent catches a run missed while the server was off. + - name: "Create Blancco evidence pack service and timer" + copy: + dest: "/etc/systemd/system/{{ item.name }}" + content: "{{ item.content }}" + loop: + - name: pxe-blancco-evidence.service + content: | + [Unit] + Description=Save last month's Blancco CMMC evidence pack to the reports share + + [Service] + Type=oneshot + WorkingDirectory=/opt/pxe-webapp + Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports + Environment=TZ=America/New_York + ExecStart=/usr/bin/python3 /opt/pxe-webapp/blancco_evidence_monthly.py + - name: pxe-blancco-evidence.timer + content: | + [Unit] + Description=Monthly Blancco CMMC evidence pack + + [Timer] + OnCalendar=*-*-01 06:00:00 + Persistent=true + + [Install] + WantedBy=timers.target + + - name: "Enable Blancco evidence pack timer" + systemd: + name: pxe-blancco-evidence.timer + state: started + enabled: yes + daemon_reload: yes + - name: "Configure unified Apache site (static files + webapp proxy)" copy: dest: /etc/apache2/sites-available/pxe-server.conf diff --git a/webapp/app.py b/webapp/app.py index e9eb6cf..c570ab1 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -40,6 +40,7 @@ from werkzeug.utils import secure_filename import config from services import ( + blancco_evidence, blancco_report, deploy, fs, @@ -541,10 +542,12 @@ def clonezilla_delete(filename): @app.route("/reports") def blancco_reports(): reports = [] + state = blancco_evidence.load_state() if os.path.isdir(config.BLANCCO_REPORTS): for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True): fpath = os.path.join(config.BLANCCO_REPORTS, f) - if not os.path.isfile(fpath): + # .part files are reports still being copied in by Blancco. + if not os.path.isfile(fpath) or f.startswith(".") or f.endswith(".part"): continue stat = os.stat(fpath) ext = os.path.splitext(f)[1].lower() @@ -554,7 +557,8 @@ def blancco_reports(): # negligible at fleet sizes). serial = "" model = "" - state = "" + result = "" + cloud = "" if ext == ".xml": try: data = blancco_report.parse(fpath) @@ -567,11 +571,13 @@ def blancco_reports(): erasures = data.get("erasures") or [] states = [(e.get("state") or "").strip() for e in erasures if e.get("state")] if not states: - state = "" + result = "" elif any(s.lower() != "successful" for s in states): - state = "Failed" + result = "Failed" else: - state = "Successful" + result = "Successful" + cloud = blancco_evidence.cloud_status( + state, (data.get("meta") or {}).get("document_id", "")) except Exception: pass reports.append({ @@ -581,11 +587,14 @@ def blancco_reports(): "type": ext.lstrip(".").upper() or "FILE", "serial": serial, "model": model, - "state": state, + "state": result, + "cloud": cloud, }) return render_template( "reports.html", reports=reports, + site=config.BLANCCO_SITE, + last_month=blancco_evidence.previous_month(), image_types=config.IMAGE_TYPES, friendly_names=config.FRIENDLY_NAMES, ) @@ -620,6 +629,7 @@ def blancco_export_reports(): # when a report has no parseable date. PDFs (7.20 writes pdf+xml) ride # along with the XML of the same name. rows, files = [], [] + cloud_state = blancco_evidence.load_state() folder = config.BLANCCO_REPORTS names = sorted(os.listdir(folder)) if os.path.isdir(folder) else [] for f in names: @@ -634,7 +644,9 @@ def blancco_export_reports(): os.stat(fpath).st_mtime, timezone.utc) if when < since: continue - rows.extend(blancco_report.export_rows(f, data, when.isoformat())) + rows.extend(blancco_report.export_rows( + f, data, when.isoformat(), + blancco_evidence.cloud_status(cloud_state, (data.get("meta") or {}).get("document_id", "")))) files.append(f) pdf = os.path.splitext(f)[0] + ".pdf" if pdf in names: @@ -664,6 +676,54 @@ def blancco_export_reports(): download_name=f"{stem}.zip") +@app.route("/reports/cloud-status", methods=["POST"]) +def blancco_cloud_status(): + # CSRF-exempt machine-to-machine endpoint: blancco-progress.py, running + # inside Blancco, reports whether each report reached the Blancco cloud. + p = request.get_json(silent=True) or {} + try: + blancco_evidence.record_cloud( + p.get("document_id", ""), bool(p.get("sent")), p.get("serial", ""), p.get("detail", "")) + except ValueError as ex: + return jsonify({"error": str(ex)}), 400 + return jsonify({"ok": True}), 200 + + +@app.route("/reports/evidence") +def blancco_evidence_pack(): + """CMMC evidence pack (ENT-056/158): one ZIP per site and month.""" + month = request.args.get("month") or blancco_evidence.previous_month() + if not re.fullmatch(r"20\d\d-(0[1-9]|1[0-2])", month): + abort(400) + out = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) + count = blancco_evidence.build_evidence_zip(month, out) + out.seek(0) + audit("REPORT_EVIDENCE", f"{month} {count} reports") + return send_file(out, mimetype="application/zip", as_attachment=True, + download_name=blancco_evidence.evidence_name(month) + ".zip") + + +@app.route("/reports/servicenow") +def blancco_servicenow(): + """What to tick on each asset's Onsite Bin Management RITM (ENT-045/159).""" + include_done = request.args.get("all") == "1" + days = request.args.get("days", 30, type=int) + items = blancco_evidence.servicenow_items(days=max(1, min(days, 366)), include_done=include_done) + return render_template("reports_servicenow.html", items=items, + include_done=include_done, days=days) + + +@app.route("/reports/servicenow/", methods=["POST"]) +def blancco_servicenow_mark(document_id): + done = request.form.get("done", "1") == "1" + try: + blancco_evidence.mark_servicenow(document_id, done) + except ValueError: + abort(400) + audit("REPORT_SERVICENOW", f"{document_id} {'done' if done else 'undone'}") + return redirect(url_for("blancco_servicenow", all=request.args.get("all"))) + + @app.route("/reports/download/") def blancco_download_report(filename): filename = secure_filename(filename) diff --git a/webapp/blancco_evidence_monthly.py b/webapp/blancco_evidence_monthly.py new file mode 100644 index 0000000..07ad9a5 --- /dev/null +++ b/webapp/blancco_evidence_monthly.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Save the monthly Blancco CMMC evidence pack to the report share. + +Run by pxe-blancco-evidence.timer on the 1st of each month: writes +/evidence/ENT-056__.zip for the previous +month (or --month YYYY-MM), so the tech only has to copy it into the +site's Box evidence folder. Same pack as the webapp's /reports/evidence. +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import config # noqa: E402 +from services import blancco_evidence # noqa: E402 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--month", default=blancco_evidence.previous_month(), + help="YYYY-MM, default: previous month") + args = ap.parse_args() + blancco_evidence.month_bounds(args.month) # validates the format + out_dir = os.path.join(config.BLANCCO_REPORTS, "evidence") + os.makedirs(out_dir, exist_ok=True) + final = os.path.join(out_dir, blancco_evidence.evidence_name(args.month) + ".zip") + tmp = final + ".part" + with open(tmp, "wb") as f: + count = blancco_evidence.build_evidence_zip(args.month, f) + os.replace(tmp, final) + print(f"{final}: {count} report(s)") + + +if __name__ == "__main__": + main() diff --git a/webapp/config.py b/webapp/config.py index 2016f74..2685fe6 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -21,6 +21,11 @@ WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html") BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim") AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log") IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging") +# Blancco evidence: site name stamped on reports and evidence packs, and a +# small JSON state file (cloud-upload confirmations, ServiceNow ticks) kept +# off the report share so it never shows up as a report. +BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "West Jefferson") +BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json") # Log sources used by services/imaging_log_tail.py to infer client progress # when no /imaging/status push has arrived yet. Override per-host if any of diff --git a/webapp/services/blancco_evidence.py b/webapp/services/blancco_evidence.py new file mode 100644 index 0000000..4d2f73a --- /dev/null +++ b/webapp/services/blancco_evidence.py @@ -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 diff --git a/webapp/services/blancco_report.py b/webapp/services/blancco_report.py index 070a38f..0621eb6 100644 --- a/webapp/services/blancco_report.py +++ b/webapp/services/blancco_report.py @@ -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] diff --git a/webapp/services/csrf.py b/webapp/services/csrf.py index 10b30ff..280f9b7 100644 --- a/webapp/services/csrf.py +++ b/webapp/services/csrf.py @@ -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", } diff --git a/webapp/templates/reports.html b/webapp/templates/reports.html index 0e47eae..5e92f8d 100644 --- a/webapp/templates/reports.html +++ b/webapp/templates/reports.html @@ -29,6 +29,23 @@

Dated by the timestamp inside each report, not the file date.

+
+
CMMC Evidence - {{ site }}
+
+
+ + + +
+ + ServiceNow tick list (ENT-045/159) + +
+

The evidence pack for last month is also saved automatically to evidence\ on the reports share on the 1st. Certificates of record are in the Blancco Management Portal.

+
+
Drive Erasure Certificates
{% if reports %} @@ -40,6 +57,7 @@ Model Date Result + Cloud Actions @@ -55,6 +73,11 @@ {% elif r.state %}{{ r.state }} {% else %}-{% endif %} + + {% if r.cloud == 'sent' %}Sent + {% elif r.cloud == 'failed' %}Not sent + {% else %}-{% endif %} + {% if r.filename.lower().endswith('.xml') %} +

ServiceNow Tick List

+
+
+ +
+
Onsite Bin Management RITM (ENT-045 / ENT-159)
+

+ For each wiped asset, open its Onsite Bin Management RITM and tick + Wiped, or Blancco Wipe failed with the destruction note below. + Then click Done here so it drops off the list. +

+
+ + + +
+
+ +
+ {% if items %} +
+ + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + {% endfor %} + +
SerialModelWiped (UTC)Tick in ServiceNowDestruction noteCloudDone
+ {{ it.serial or '-' }} + {% if it.serial %}{% endif %} + {{ it.model or '-' }}{{ it.when.strftime('%Y-%m-%d %H:%M') }} + {% if it.failed %}Blancco Wipe failed + {% else %}Wiped{% endif %} + + {% if it.note %} +
{{ it.note }}
+ + {% else %}-{% endif %} +
+ {% if it.cloud == 'sent' %}Sent + {% elif it.cloud == 'failed' %}Not sent + {% else %}-{% endif %} + +
+ + {% if it.done %} + + + {% else %} + + + {% endif %} +
+
+
+ {% else %} +
+

Nothing left to tick for the last {{ days }} days.

+
+ {% endif %} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %}