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

@@ -291,33 +291,39 @@ cat > /run/newroot/usr/local/sbin/blancco-report-sync << 'XEOF'
#!/bin/bash #!/bin/bash
# Copy Blancco reports to the SMB report share. # Copy Blancco reports to the SMB report share.
# #
# In process=auto Albus saves each report to /reports/<document_id>.xml and # In process=auto Albus saves each report to /reports/<document_id>.xml
# uploads it to the cloud, but never writes the share: auto_backup is # (and .pdf when pdf+xml is selected) and uploads it to the cloud, but never
# forced off outside workflow/manual mode. Albus has the share mounted at # writes the share: auto_backup is forced off outside workflow/manual mode.
# /mnt/share already, so copy each finished report there, named the way a # Albus has the share mounted at /mnt/share already, so copy each finished
# manual Save names it (UTC YYYYMMDD_HHMMSS_report.xml). # file there, named the way a manual Save names it (UTC
# YYYYMMDD_HHMMSS_report.<ext>, the time taken from the XML so the PDF of
# the same report gets the same name).
# Usage: blancco-report-sync [--once] # Usage: blancco-report-sync [--once]
DONE=/var/lib/blancco-report-sync DONE=/var/lib/blancco-report-sync
mkdir -p "$DONE" mkdir -p "$DONE"
while :; do while :; do
for f in /reports/*.xml; do for f in /reports/*.xml /reports/*.pdf; do
[ -s "$f" ] || continue [ -s "$f" ] || continue
id=$(basename "$f" .xml) base=$(basename "$f")
[ -e "$DONE/$id" ] && continue [ -e "$DONE/$base" ] && continue
# Leave files Albus may still be writing. # Leave files Albus may still be writing.
[ $(( $(date +%s) - $(stat -c %Y "$f") )) -ge 5 ] || continue [ $(( $(date +%s) - $(stat -c %Y "$f") )) -ge 5 ] || continue
mountpoint -q /mnt/share || 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 if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then
touch "$DONE/$id" touch "$DONE/$base"
echo "copied $id to $name" echo "copied $base to $name"
serial=$(tr -d '"\\' < /sys/class/dmi/id/product_serial 2>/dev/null) 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' \ [ -n "$serial" ] && curl -s -m 5 -o /dev/null -H 'Content-Type: application/json' \
-d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \ -d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \
http://172.16.9.1:9009/imaging/status || true http://172.16.9.1:9009/imaging/status || true
else else
rm -f "/mnt/share/$name.part" rm -f "/mnt/share/$name.part"
echo "copy of $id failed, will retry" echo "copy of $base failed, will retry"
fi fi
done done
[ "$1" = "--once" ] && exit 0 [ "$1" = "--once" ] && exit 0

View File

@@ -252,7 +252,7 @@
<creation_date>2026-06-22</creation_date> <creation_date>2026-06-22</creation_date>
</image> </image>
<customer_data> <customer_data>
<business_location></business_location> <business_location>West Jefferson</business_location>
<customer_license>GE Aerospace</customer_license> <customer_license>GE Aerospace</customer_license>
<erasure_person></erasure_person> <erasure_person></erasure_person>
<erasure_provider></erasure_provider> <erasure_provider></erasure_provider>
@@ -312,8 +312,8 @@
<per_drive>false</per_drive> <per_drive>false</per_drive>
<auto_backup>true</auto_backup> <auto_backup>true</auto_backup>
<extensions type="array" key="extension"> <extensions type="array" key="extension">
<extension selected="true">xml</extension> <extension>xml</extension>
<extension>pdf+xml</extension> <extension selected="true">pdf+xml</extension>
</extensions> </extensions>
<utc_offset>0</utc_offset> <utc_offset>0</utc_offset>
<network_info>true</network_info> <network_info>true</network_info>

View File

@@ -21,6 +21,7 @@ import time
import urllib.request import urllib.request
STATUS_URL = "http://172.16.9.1:9009/imaging/status" STATUS_URL = "http://172.16.9.1:9009/imaging/status"
CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status"
STAGE_TOTAL = 7 STAGE_TOTAL = 7
# Progress lines arrive in bursts; post at most this often unless the # Progress lines arrive in bursts; post at most this often unless the
# stage itself changes. # stage itself changes.
@@ -69,6 +70,11 @@ RULES = [
(re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None), (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"), (re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"),
] ]
# Cloud upload of a report: "[comm.manager] Sending report document id: <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 # Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test
# completion and volume lines that also carry a percentage. # completion and volume lines that also carry a percentage.
PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I) PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I)
@@ -115,10 +121,35 @@ class Reporter:
self.pending = [] self.pending = []
self.last_post = now 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): def line(self, text):
text = text.strip() text = text.strip()
if not text: if not text:
return 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: for pat, idx, stage, status in RULES:
m = pat.search(text) m = pat.search(text)
if not m: if not m:

View File

@@ -1148,6 +1148,44 @@
enabled: yes enabled: yes
daemon_reload: 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)" - name: "Configure unified Apache site (static files + webapp proxy)"
copy: copy:
dest: /etc/apache2/sites-available/pxe-server.conf dest: /etc/apache2/sites-available/pxe-server.conf

View File

@@ -40,6 +40,7 @@ from werkzeug.utils import secure_filename
import config import config
from services import ( from services import (
blancco_evidence,
blancco_report, blancco_report,
deploy, deploy,
fs, fs,
@@ -541,10 +542,12 @@ def clonezilla_delete(filename):
@app.route("/reports") @app.route("/reports")
def blancco_reports(): def blancco_reports():
reports = [] reports = []
state = blancco_evidence.load_state()
if os.path.isdir(config.BLANCCO_REPORTS): if os.path.isdir(config.BLANCCO_REPORTS):
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True): for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
fpath = os.path.join(config.BLANCCO_REPORTS, f) 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 continue
stat = os.stat(fpath) stat = os.stat(fpath)
ext = os.path.splitext(f)[1].lower() ext = os.path.splitext(f)[1].lower()
@@ -554,7 +557,8 @@ def blancco_reports():
# negligible at fleet sizes). # negligible at fleet sizes).
serial = "" serial = ""
model = "" model = ""
state = "" result = ""
cloud = ""
if ext == ".xml": if ext == ".xml":
try: try:
data = blancco_report.parse(fpath) data = blancco_report.parse(fpath)
@@ -567,11 +571,13 @@ def blancco_reports():
erasures = data.get("erasures") or [] erasures = data.get("erasures") or []
states = [(e.get("state") or "").strip() for e in erasures if e.get("state")] states = [(e.get("state") or "").strip() for e in erasures if e.get("state")]
if not states: if not states:
state = "" result = ""
elif any(s.lower() != "successful" for s in states): elif any(s.lower() != "successful" for s in states):
state = "Failed" result = "Failed"
else: else:
state = "Successful" result = "Successful"
cloud = blancco_evidence.cloud_status(
state, (data.get("meta") or {}).get("document_id", ""))
except Exception: except Exception:
pass pass
reports.append({ reports.append({
@@ -581,11 +587,14 @@ def blancco_reports():
"type": ext.lstrip(".").upper() or "FILE", "type": ext.lstrip(".").upper() or "FILE",
"serial": serial, "serial": serial,
"model": model, "model": model,
"state": state, "state": result,
"cloud": cloud,
}) })
return render_template( return render_template(
"reports.html", "reports.html",
reports=reports, reports=reports,
site=config.BLANCCO_SITE,
last_month=blancco_evidence.previous_month(),
image_types=config.IMAGE_TYPES, image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES, 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 # when a report has no parseable date. PDFs (7.20 writes pdf+xml) ride
# along with the XML of the same name. # along with the XML of the same name.
rows, files = [], [] rows, files = [], []
cloud_state = blancco_evidence.load_state()
folder = config.BLANCCO_REPORTS folder = config.BLANCCO_REPORTS
names = sorted(os.listdir(folder)) if os.path.isdir(folder) else [] names = sorted(os.listdir(folder)) if os.path.isdir(folder) else []
for f in names: for f in names:
@@ -634,7 +644,9 @@ def blancco_export_reports():
os.stat(fpath).st_mtime, timezone.utc) os.stat(fpath).st_mtime, timezone.utc)
if when < since: if when < since:
continue 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) files.append(f)
pdf = os.path.splitext(f)[0] + ".pdf" pdf = os.path.splitext(f)[0] + ".pdf"
if pdf in names: if pdf in names:
@@ -664,6 +676,54 @@ def blancco_export_reports():
download_name=f"{stem}.zip") 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>") @app.route("/reports/download/<filename>")
def blancco_download_report(filename): def blancco_download_report(filename):
filename = secure_filename(filename) filename = secure_filename(filename)

View File

@@ -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
<BLANCCO_REPORTS>/evidence/ENT-056_<Site>_<YYYY-MM>.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()

View File

@@ -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") BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log") AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging") 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 # 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 # when no /imaging/status push has arrived yet. Override per-host if any of

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", "system_manufacturer", "system_model", "system_serial",
"drive_model", "drive_serial", "drive_capacity_gb", "drive_interface", "drive_model", "drive_serial", "drive_capacity_gb", "drive_interface",
"erasure_standard", "state", "start_time", "end_time", "elapsed_time", "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]: def _as_list(x):
"""One CSV row per erased drive; a report with no erasures still gets a row.""" 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 {} meta = data.get("meta") or {}
system = (data.get("hardware") or {}).get("system") or {} system = (data.get("hardware") or {}).get("system") or {}
if isinstance(system, list): if isinstance(system, list):
@@ -145,6 +187,8 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
"system_serial": system.get("serial", ""), "system_serial": system.get("serial", ""),
"license_consumption_id": ";".join(data.get("license_consumption_ids") or []), "license_consumption_id": ";".join(data.get("license_consumption_ids") or []),
"business_name": (data.get("company") or {}).get("business_name", ""), "business_name": (data.get("company") or {}).get("business_name", ""),
"business_location": (data.get("company") or {}).get("business_location", ""),
"cloud_upload": cloud,
} }
rows = [] rows = []
for er in data.get("erasures") or []: 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", ""), "end_time": er.get("end_time", ""),
"elapsed_time": er.get("elapsed_time", ""), "elapsed_time": er.get("elapsed_time", ""),
"total_errors": er.get("total_errors", ""), "total_errors": er.get("total_errors", ""),
"failure_reason": failure_reason(er),
}) })
return rows or [base] 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. # trust-by-network. Keep the list short and explicit.
CSRF_EXEMPT_PATHS = { CSRF_EXEMPT_PATHS = {
"/imaging/status", "/imaging/status",
"/reports/cloud-status",
} }

View File

@@ -29,6 +29,23 @@
<p class="small text-muted mt-2 mb-0">Dated by the timestamp inside each report, not the file date.</p> <p class="small text-muted mt-2 mb-0">Dated by the timestamp inside each report, not the file date.</p>
</div> </div>
<div class="section-card">
<div class="section-title">CMMC Evidence - {{ site }}</div>
<div class="d-flex flex-wrap align-items-center gap-3">
<form method="get" action="{{ url_for('blancco_evidence_pack') }}" class="d-flex flex-wrap align-items-center gap-2">
<label for="evidenceMonth" class="mb-0">Evidence pack (ENT-056/158) for</label>
<input id="evidenceMonth" type="month" name="month" value="{{ last_month }}" class="form-control form-control-sm w-auto" required>
<button type="submit" class="pxe-btn pxe-btn-secondary" title="summary.csv plus every report's XML and PDF for that month">
<i class="bi bi-file-zip"></i> Download
</button>
</form>
<a href="{{ url_for('blancco_servicenow') }}" class="pxe-btn pxe-btn-secondary" title="What to tick on each Onsite Bin Management RITM">
<i class="bi bi-list-check"></i> ServiceNow tick list (ENT-045/159)
</a>
</div>
<p class="small text-muted mt-2 mb-0">The evidence pack for last month is also saved automatically to <span class="mono">evidence\</span> on the reports share on the 1st. Certificates of record are in the Blancco Management Portal.</p>
</div>
<div class="section-card"> <div class="section-card">
<div class="section-title">Drive Erasure Certificates</div> <div class="section-title">Drive Erasure Certificates</div>
{% if reports %} {% if reports %}
@@ -40,6 +57,7 @@
<th>Model</th> <th>Model</th>
<th>Date</th> <th>Date</th>
<th>Result</th> <th>Result</th>
<th title="Whether Blancco reported uploading this report to the Blancco cloud">Cloud</th>
<th class="text-end">Actions</th> <th class="text-end">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -55,6 +73,11 @@
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span> {% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
{% else %}<span class="text-muted">-</span>{% endif %} {% else %}<span class="text-muted">-</span>{% endif %}
</td> </td>
<td>
{% if r.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
{% elif r.cloud == 'failed' %}<span class="badge badge-danger" title="Resend from Blancco, or upload the XML to the Blancco portal">Not sent</span>
{% else %}<span class="text-muted" title="No upload status recorded (older report, or saved manually)">-</span>{% endif %}
</td>
<td class="actions text-end text-nowrap"> <td class="actions text-end text-nowrap">
{% if r.filename.lower().endswith('.xml') %} {% if r.filename.lower().endswith('.xml') %}
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}" <a href="{{ url_for('blancco_view_report', filename=r.filename) }}"

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}ServiceNow Tick List - PXE Server Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-list-check"></i> ServiceNow Tick List</h1>
<div class="header-actions">
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> Erasure reports</a>
</div>
</div>
<div class="section-card">
<div class="section-title">Onsite Bin Management RITM (ENT-045 / ENT-159)</div>
<p class="mb-2">
For each wiped asset, open its Onsite Bin Management RITM and tick
<strong>Wiped</strong>, or <strong>Blancco Wipe failed</strong> with the destruction note below.
Then click <strong>Done</strong> here so it drops off the list.
</p>
<form method="get" class="d-flex flex-wrap align-items-center gap-2 mb-0">
<label for="days" class="mb-0">Last</label>
<select id="days" name="days" class="form-select form-select-sm w-auto" onchange="this.form.submit()">
{% for d in (7, 30, 90, 366) %}<option value="{{ d }}" {% if d == days %}selected{% endif %}>{{ d }} days</option>{% endfor %}
</select>
<label class="mb-0 ms-2"><input type="checkbox" name="all" value="1" {% if include_done %}checked{% endif %} onchange="this.form.submit()"> show done</label>
</form>
</div>
<div class="section-card">
{% if items %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Serial</th>
<th>Model</th>
<th>Wiped (UTC)</th>
<th>Tick in ServiceNow</th>
<th>Destruction note</th>
<th>Cloud</th>
<th class="text-end">Done</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr title="{{ it.file }}">
<td class="text-nowrap">
<span class="mono">{{ it.serial or '-' }}</span>
{% if it.serial %}<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn" data-copy-text="{{ it.serial }}">copy</button>{% endif %}
</td>
<td>{{ it.model or '-' }}</td>
<td class="mono text-nowrap">{{ it.when.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
{% if it.failed %}<span class="badge badge-danger">Blancco Wipe failed</span>
{% else %}<span class="badge badge-success">Wiped</span>{% endif %}
</td>
<td style="max-width: 28rem;">
{% if it.note %}
<div class="small">{{ it.note }}</div>
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn mt-1" data-copy-text="{{ it.note }}">copy note</button>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
{% if it.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
{% elif it.cloud == 'failed' %}<span class="badge badge-danger">Not sent</span>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.document_id, all='1' if include_done else None) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% if it.done %}
<input type="hidden" name="done" value="0">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Ticked {{ it.done }}">Undo</button>
{% else %}
<input type="hidden" name="done" value="1">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Done</button>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<p class="mb-1">Nothing left to tick for the last {{ days }} days.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.querySelectorAll('.copy-btn').forEach(function (b) {
b.addEventListener('click', function () {
var t = b.getAttribute('data-copy-text') || '';
var done = function () { var o = b.textContent; b.textContent = 'copied'; setTimeout(function () { b.textContent = o; }, 1200); };
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(t).then(done); return; }
var ta = document.createElement('textarea'); ta.value = t; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } finally { document.body.removeChild(ta); }
});
});
</script>
{% endblock %}