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

@@ -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/<document_id>", 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/<filename>")
def blancco_download_report(filename):
filename = secure_filename(filename)