Webapp redesign: fixes from the code review

Blocker
- /reports/export returned 500: REPORT_EXPORT_RANGES and _months_ago were
  deleted with the old reports route. Restored.

Major
- A session without stage_index/stage_total took down /, /imaging and
  /imaging/tiles (int filter on Undefined). The templates default both;
  saving a LAPS password for a session that no longer exists is refused
  (it created a bare session).
- /reports/todo parsed every report four times (23 s at 700 reports).
  blancco_report.parse() now caches by path, mtime and size: 0.09 s.
- Stored XSS through the session serial in the Clear confirm (the
  browser decodes ' before running an inline handler). The message
  moves to data-confirm, and update_session() only accepts serials of
  letters, digits, '.', '_' and '-' (400 from /imaging/status otherwise).
- Reports without a document id and with odd characters in the file name
  got a key the mark routes rejected, so they could never be cleared.
  report_key() now always produces a valid key.

Minor
- failure_note() handles a list-valued erasure target (500 on the report
  and to-do pages).
- _recently_marked() tolerates naive, 'Z' and non-dict state entries.
- Key regexes use fullmatch (a trailing newline was accepted).
- Unreadable reports are no longer counted as to tick / to destroy; the
  reports page already flags them as Unreadable.
- Rollback review recounts restorable files after reading the backups.
- Enrollment: the startnet fallback package (no or empty ppkg.conf) and a
  quoted SOURCE_PPKG are protected too.
- Backup upload overwrite check is case-insensitive (SMB share) and
  replaces the existing file rather than adding a case twin.
- Overview polling keeps the last good rows on an error and pauses while
  the tab is hidden. A shown or typed LAPS password survives the floor's
  refresh.
- hardware_dismiss only redirects to same-site paths; tiles ?limit is
  clamped; the Updates overview survives an unreadable Dell scan or iPXE
  state; describe() uses aversion(); a client cannot label its session
  'inferred'.
This commit is contained in:
cproudlock
2026-10-14 14:42:21 -04:00
parent fd00d5ff89
commit 2bcc9d8016
10 changed files with 131 additions and 36 deletions

View File

@@ -38,7 +38,11 @@ _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>"."""
return document_id if _DOC_ID.match(document_id or "") else f"file:{filename}"
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 ------------------------------------------------------------------
@@ -68,7 +72,7 @@ def _now() -> str:
def record_cloud(document_id: str, sent: bool, serial: str = "", detail: str = ""):
if not _DOC_ID.match(document_id or ""):
if not _DOC_ID.fullmatch(document_id or ""):
raise ValueError("bad document_id")
state = load_state()
state["cloud"][document_id] = {
@@ -80,7 +84,7 @@ def record_cloud(document_id: str, sent: bool, serial: str = "", detail: str = "
def mark_servicenow(document_id: str, done: bool = True):
if not _REPORT_KEY.match(document_id or ""):
if not _REPORT_KEY.fullmatch(document_id or ""):
raise ValueError("bad document_id")
state = load_state()
if done:
@@ -118,10 +122,11 @@ def collect(since=None, until=None) -> list[dict]:
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 = {}
data, unreadable = {}, True
when = blancco_report.report_datetime(data) or datetime.fromtimestamp(
os.stat(path).st_mtime, timezone.utc)
if since and when < since:
@@ -135,6 +140,7 @@ def collect(since=None, until=None) -> list[dict]:
"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
@@ -197,6 +203,14 @@ def build_evidence_zip(month: str, fileobj) -> int:
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
@@ -210,7 +224,7 @@ def failure_note(data: dict):
return failed, ""
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()
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'}. "
@@ -223,6 +237,8 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
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
@@ -260,7 +276,7 @@ _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.match(key or ""):
if not pattern or not pattern.fullmatch(key or ""):
raise ValueError("bad to-do item")
state = load_state()
bucket = state.setdefault(kind, {})

View File

@@ -18,6 +18,7 @@ Returned shape (best-effort; missing keys are OK in templates):
}
"""
from __future__ import annotations
import os
import xml.etree.ElementTree as ET
@@ -52,7 +53,28 @@ def _text(parent, path, default=""):
return el.text.strip()
# Parsed reports by path, reused while the file's size and mtime are
# unchanged. The reports list, the to-do (which walks a year of reports
# several times per request), exports and evidence packs all parse the same
# files; a report on the share is never edited in place. Callers must not
# mutate the returned dict.
_parse_cache: dict = {}
def parse(path: str) -> dict:
st = os.stat(path)
sig = (st.st_mtime_ns, st.st_size)
hit = _parse_cache.get(path)
if hit and hit[0] == sig:
return hit[1]
data = _parse(path)
if len(_parse_cache) > 5000: # far above a site's report count; just a bound
_parse_cache.clear()
_parse_cache[path] = (sig, data)
return data
def _parse(path: str) -> dict:
tree = ET.parse(path)
root = tree.getroot()
rep = root.find(".//report/blancco_data") or root

View File

@@ -1379,9 +1379,6 @@ def file_status(rec):
return out
_PACK_VER = re.compile(r"_(a\d+)", re.I)
def _model_for_zip(zip_name, maps):
"""First model token of the map entry that uses zip_name, or ''."""
for m in maps.values():
@@ -1399,9 +1396,9 @@ def describe(rec, maps=None):
out = []
for d in rec.get("drivers") or []:
model = _model_for_zip(d.get("new", ""), maps) or _model_for_zip(d.get("old", ""), maps)
a, b = _PACK_VER.search(d.get("old", "") or ""), _PACK_VER.search(d.get("new", "") or "")
if model and a and b:
out.append(f"{model} pack {a.group(1).upper()} to {b.group(1).upper()}")
a, b = aversion(d.get("old", "")), aversion(d.get("new", ""))
if model and a is not None and b is not None:
out.append(f"{model} pack A{a:02d} to A{b:02d}")
else:
out.append(f"{d.get('old', '')} to {d.get('new', '')}")
for b in rec.get("bios") or []:
@@ -1448,6 +1445,8 @@ def rollback_preview(apply_id, max_lines=400):
if len(lines) > max_lines:
lines = lines[:max_lines] + [f"... {len(lines) - max_lines} more lines"]
f["diff"] = "\n".join(lines)
# A file whose backup could not be read above is no longer restorable.
rec["unchanged"] = sum(1 for f in rec["files"] if f["state"] == "unchanged")
return rec

View File

@@ -86,6 +86,13 @@ def update_session(payload: dict) -> dict:
serial = (payload.get("serial") or "").strip()
if not serial:
raise ValueError("payload missing 'serial'")
# "inferred" is the server's own label for log-only bays; a client that
# sends it would hide its row from the floor's failure-first order.
if payload.get("source") not in (None, "client", "blancco"):
payload = {k: v for k, v in payload.items() if k != "source"}
# Serials end up in file names and in page markup: keep them plain.
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", serial):
raise ValueError("serial must be 1-64 letters, digits, '.', '_' or '-'")
_ensure_dir()
path = _path_for(serial)
@@ -234,7 +241,7 @@ def hide_stale(sessions: list[dict], hours: float = ACTIVE_WINDOW_HOURS) -> tupl
when = datetime.fromisoformat(s.get("last_updated") or "")
if when.tzinfo is None:
when = when.astimezone()
except ValueError:
except (ValueError, TypeError):
when = None
if when is not None and when < cutoff:
hidden += 1