webapp: fix review findings (imaging types, NIC status, iPXE gates)

- imaging: coerce client payload and stored session fields to str/int,
  drop lists/dicts; tiles template guards pctype/current_stage; POST
  rejects non-object bodies and non-string serials.
- nic_status: normalise every check-file entry; load nic.conf as-is with
  a [nic] fallback; split interface= lists; keep the check's binding while
  fresh, resolve wildcard sockets from the dnsmasq pin (never True for an
  excluded NIC); mark vanished NICs down; include role bridges; stale or
  future-dated files downgrade to warnings and drop evidence.
- Overview renders the network pill in its own try and shows
  'Network: unknown' when status fails.
- ipxe_update: read every Authenticode signature (all WIN_CERTIFICATE
  entries plus nested SPC_NESTED_SIGNATURE) with openssl; shim swap and
  install need both Microsoft UEFI CA 2011 and 2023; install re-reads
  each file (snponly ipxe.efi only); staged.json schema version, older
  records shown stale and refused; uhci counts as native USB.
- backups describe their files; risky rollbacks need a confirm flag.
- mac_folders: guarded listdir, cached describe, configured TFTP root,
  warning for a shim without ipxe.efi.
- evidence pack: years 2000..2100, 400 days inclusive.
- a11y role=group on quick ranges; progress bar stays full during 'shim'.
- tests/test_review_fixes.py: 29 unittest cases incl. both real shims.
This commit is contained in:
cproudlock
2026-10-15 14:20:24 -04:00
parent 53d39bfc50
commit 606659bf07
11 changed files with 1185 additions and 166 deletions

View File

@@ -24,8 +24,10 @@ https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz, with
the signed x86 files in ipxeboot/x86_64-sb/. GitHub lists a sha256 digest per
release asset, which is checked before anything is unpacked, and every EFI
file's Authenticode chain is read with sbverify before it can be installed.
The chain check is by certificate name (sbverify --list); the cryptographic
trust comes from the GitHub digest. Do not swap in `sbverify --cert`: it
The chain check is by certificate name (sbverify --list, plus every
signature in the PE security directory, nested ones included, read with
openssl for the shim's Microsoft CAs); the cryptographic trust comes from
the GitHub digest. Do not swap in `sbverify --cert`: it
prints "Signature verification OK" even for a missing or unrelated cert.
Why it matters: the Broadcom build, and the shim in the iPXE tarball, are
@@ -85,6 +87,12 @@ NEED_LABEL = {"microsoft": "a Microsoft UEFI CA", "ipxe": "the iPXE Secure Boot
MS_CA_2011 = "Microsoft Corporation UEFI CA 2011"
MS_CA_2023 = "Microsoft UEFI CA 2023"
IPXE_CA = "iPXE Secure Boot CA"
MS_CAS = (MS_CA_2011, MS_CA_2023)
# Bumped when the staged.json layout or the checks behind it change. A
# record written by an older version was verified by weaker checks and is
# shown as stale (download it again) and never installed.
STAGED_SCHEMA = 2
def _dir():
@@ -115,6 +123,111 @@ def signers(path):
return pairs
# Authenticode nested signature (SPC_NESTED_SIGNATURE, 1.3.6.1.4.1.311.2.4.1),
# DER-encoded OID. A second signature can sit in a second WIN_CERTIFICATE
# entry or nested inside the first one's unsigned attributes; sbverify may
# list only the outer one, so both places are read here.
NESTED_SIG_OID = bytes.fromhex("060a2b060104018237020401")
WIN_CERT_TYPE_PKCS_SIGNED_DATA = 0x0002
def _der_tlv(buf, i):
"""(content start, content end) of the DER element at buf[i]; raises
ValueError when it runs past the buffer."""
if i + 2 > len(buf):
raise ValueError("short DER")
n = buf[i + 1]
if n < 0x80:
start, length = i + 2, n
else:
k = n & 0x7F
if not 1 <= k <= 4 or i + 2 + k > len(buf):
raise ValueError("bad DER length")
start, length = i + 2 + k, int.from_bytes(buf[i + 2:i + 2 + k], "big")
if start + length > len(buf):
raise ValueError("DER runs past the buffer")
return start, start + length
def _pe_signatures(data):
"""The PKCS#7 blobs in a PE file's security directory, [] if none."""
try:
pe = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe:pe + 4] != b"PE\0\0":
return []
magic = struct.unpack_from("<H", data, pe + 24)[0]
dirs = pe + 24 + (96 if magic == 0x10B else 112)
off, size = struct.unpack_from("<II", data, dirs + 4 * 8)
except struct.error:
return []
out, i, end = [], off, min(off + size, len(data))
while off and i + 8 <= end:
length, _rev, ctype = struct.unpack_from("<IHH", data, i)
if length < 8:
break
if ctype == WIN_CERT_TYPE_PKCS_SIGNED_DATA:
blob = data[i + 8:min(i + length, end)]
try:
out.append(blob[:_der_tlv(blob, 0)[1]]) # drop the 8-byte padding
except ValueError:
pass
i += (length + 7) & ~7
return out
def _nested_signatures(blob, depth=0):
"""ContentInfo blobs nested in `blob` under SPC_NESTED_SIGNATURE, deep
nests included."""
out = []
pos = blob.find(NESTED_SIG_OID)
while pos >= 0 and depth < 4:
try:
i = pos + len(NESTED_SIG_OID)
if blob[i] == 0x31: # SET OF ContentInfo
start, end = _der_tlv(blob, i)
j = start
while j < end:
_cs, ce = _der_tlv(blob, j)
inner = blob[j:ce]
if blob[j] == 0x30:
out.append(inner)
out += _nested_signatures(inner, depth + 1)
j = ce
except (ValueError, IndexError):
pass
pos = blob.find(NESTED_SIG_OID, pos + 1)
return out
def _cert_issuers(blob):
"""Issuer CNs of every certificate in one PKCS#7 blob (openssl)."""
r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-print_certs", "-noout"],
input=blob, capture_output=True, timeout=30)
text = r.stdout.decode("utf-8", errors="replace")
return {m.strip() for m in re.findall(r"^issuer=.*?CN\s*=\s*([^,/\n]+)", text, re.M)}
def microsoft_cas(path):
"""Which of the Microsoft UEFI CAs (2011, 2023) sign this file, looking
at every signature: each WIN_CERTIFICATE entry and any nested inside one.
Raises FileNotFoundError when openssl is not installed."""
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return []
issuers = set()
for blob in _pe_signatures(data):
for b in [blob] + _nested_signatures(blob):
issuers |= _cert_issuers(b)
return [ca for ca in MS_CAS if ca in issuers]
def dual_signed(info):
"""True when a described file carries both Microsoft UEFI CAs."""
return bool(info) and all(ca in (info.get("ms_cas") or []) for ca in MS_CAS)
def signature_kind(pairs):
"""microsoft-2023 | microsoft-2011 | microsoft | ipxe | other | unsigned.
A file signed under both Microsoft CAs counts as 2023."""
@@ -221,29 +334,74 @@ def drivers_of(path):
return ""
if b"iPXE" not in data or b"$Version: " in data:
return "" # not iPXE, or a shim
return "native" if re.search(rb"\0xhci\0|\0ehci\0", data) else "firmware"
return "native" if re.search(rb"\0(?:xhci|ehci|uhci)\0", data) else "firmware"
_DESCRIBE_CACHE = {}
def describe(path):
"""Size, hash, version, signers and drivers of one boot file, or None.
Cached per (path, size, mtime): reading and sbverify-ing every file on
each page load is slow. Returns a copy the caller may change."""
try:
st = os.stat(path)
except OSError:
return None
if not os.path.isfile(path):
return None
st = os.stat(path)
key = (os.path.abspath(path), st.st_size, st.st_mtime_ns)
hit = _DESCRIBE_CACHE.get(key)
if hit is None:
hit = _describe(path, st)
if len(_DESCRIBE_CACHE) > 256:
_DESCRIBE_CACHE.clear()
_DESCRIBE_CACHE[key] = hit
return dict(hit)
def _describe(path, st):
efi = path.endswith(".efi")
pairs, error = [], ""
pairs, error, ms_cas = [], "", []
if efi:
try:
pairs = signers(path)
except (OSError, subprocess.SubprocessError) as ex:
error = f"sbverify failed: {ex}"
return {
kind = (signature_kind(pairs) if not error else "error") if efi else "bios"
if kind.startswith("microsoft"):
try:
ms_cas = microsoft_cas(path)
except (OSError, subprocess.SubprocessError) as ex:
error = f"openssl failed: {ex}"
kind = "error"
info = {
"size": st.st_size, "modified": st.st_mtime,
"sha256": uc.file_hash(path), "version": version_of(path),
"sbat": sbat_generations(path) if efi else "",
"signers": pairs, "kind": (signature_kind(pairs) if not error else "error") if efi else "bios",
"signers": pairs, "kind": kind,
"trusts": vendor_cert_cn(path) if efi else None,
"drivers": drivers_of(path) if efi else "",
"sig_error": error,
"sig_error": error, "ms_cas": ms_cas,
}
info["dual"] = dual_signed(info)
return info
def risks(name, info):
"""Why a boot file should not go live, [] when none: an ipxe.efi that is
not the snponly (firmware drivers) build, or a shim that does not carry
both Microsoft UEFI CAs."""
if not info:
return []
if name == "ipxe.efi" and info.get("drivers") != "firmware":
if info.get("drivers") == "native":
return ["ipxe.efi drives USB itself (not snponly): USB keyboards stop working at the iPXE menu"]
return ["ipxe.efi is not recognised as an iPXE snponly build"]
if name in SHIMS and not info.get("dual"):
have = " and ".join(info.get("ms_cas") or []) or info.get("kind") or "no Microsoft CA"
return [f"{name} is not dual-signed (2011 + 2023): it carries {have}"]
return []
def _installed_log():
@@ -289,14 +447,25 @@ def mac_folders():
folder = os.path.join(config.TFTP_ROOT, d)
if not (MAC_DIR_RE.match(d) and os.path.isdir(folder)):
continue
files = []
for f in sorted(os.listdir(folder)):
files, warnings = [], []
try:
entries = sorted(os.listdir(folder))
except OSError as ex:
entries = []
warnings.append(f"cannot read the folder: {ex.strerror or ex}")
for f in entries:
info = describe(os.path.join(folder, f))
if info:
files.append({"name": f, "info": info})
by = {f["name"]: f["info"] for f in files}
_check_chain(next((by[n] for n in SHIMS if n in by), None), [by.get("ipxe.efi")])
out.append({"mac": d.replace("-", ":"), "dir": d, "files": files})
shim = next((by[n] for n in SHIMS if n in by), None)
if shim is None:
shim = next((f["info"] for f in files if (f["info"].get("version") or "").startswith("shim ")), None)
_check_chain(shim, [by.get("ipxe.efi")])
if shim is not None and "ipxe.efi" not in by:
warnings.append("This folder has a shim but no ipxe.efi. The shim loads ipxe.efi from this folder "
"(no fallback to the root), so this client stops after the shim.")
out.append({"mac": d.replace("-", ":"), "dir": d, "files": files, "warnings": warnings})
return out
@@ -530,10 +699,21 @@ def stage(info=None):
if shim.get("ok") and ipxe.get("ok") and shim.get("trusts"):
if shim["trusts"] not in {i for _, i in ipxe["signers"]}:
ipxe.update(ok=False, error=f"not signed under {shim['trusts']}, which this shim trusts")
if ipxe.get("ok") and ipxe.get("drivers") == "native":
# The full build would kill USB keyboards on every client.
ipxe.update(ok=False, error="this build drives USB itself (not snponly); refusing it as ipxe.efi")
record = {"tag": info["tag"], "published": info.get("published"), "folder": folder,
if ipxe.get("ok") and ipxe.get("drivers") != "firmware":
# The full build would kill USB keyboards on every client; anything
# not recognised as snponly is refused too.
ipxe.update(ok=False, error=("this build drives USB itself (not snponly); refusing it as ipxe.efi"
if ipxe.get("drivers") == "native" else
"not recognised as an iPXE snponly build; refusing it as ipxe.efi"))
for n in SHIMS:
f = files.get(n) or {}
if f.get("ok") and not f.get("dual"):
# Kept on disk and shown, but never installed: a 2011-only shim
# stops booting once machines drop or revoke the 2011 CA.
have = " and ".join(f.get("ms_cas") or []) or f.get("kind")
f.update(ok=False, error=f"not dual-signed (needs {MS_CA_2011} and {MS_CA_2023}; has {have}). "
+ (f.get("note") or ""))
record = {"schema": STAGED_SCHEMA, "tag": info["tag"], "published": info.get("published"), "folder": folder,
"tarball_sha256": info["sha256"], "files": files, "staged": uc.now()}
uc.write_json(os.path.join(folder, "staged.json"), record)
return record
@@ -541,10 +721,11 @@ def stage(info=None):
def _use_dual_signed_shim(folder, files):
"""Swap the tarball's shim for the ipxe/shim release build when that one
is signed under the Microsoft UEFI CA 2023 and trusts the same iPXE CA.
Any problem leaves the tarball shim in place, with the reason noted."""
carries both Microsoft UEFI CAs (2011 and 2023, read from every
signature, nested ones included) and trusts the same iPXE CA. Any
problem leaves the tarball shim in place, with the reason noted."""
tar_shim = files.get("ipxe-shim.efi") or {}
if not tar_shim.get("ok") or tar_shim.get("kind") == "microsoft-2023":
if not tar_shim.get("ok") or tar_shim.get("dual"):
return
try:
rel = json.loads(uc.fetch_text(SHIM_API, timeout=20))
@@ -559,8 +740,12 @@ def _use_dual_signed_shim(folder, files):
os.remove(dest)
uc.download(asset["browser_download_url"], dest, expect=sha, timeout=300)
d = describe(dest)
if d["kind"] != "microsoft-2023":
raise RuntimeError(f"{SHIM_ASSET} is {d['kind']}, not signed under {MS_CA_2023}")
if d["sig_error"]:
raise RuntimeError(f"{SHIM_ASSET}: {d['sig_error']}")
if not d["dual"]:
have = " and ".join(d["ms_cas"]) or d["kind"]
raise RuntimeError(f"{SHIM_ASSET} is not signed under both {MS_CA_2011} and {MS_CA_2023} "
f"(found {have})")
if d.get("trusts") != tar_shim.get("trusts"):
raise RuntimeError(f"{SHIM_ASSET} trusts {d.get('trusts')}, the tarball's iPXE needs "
f"{tar_shim.get('trusts')}")
@@ -574,7 +759,18 @@ def _use_dual_signed_shim(folder, files):
if n in files:
shutil.copy2(dest, os.path.join(folder, n))
files[n] = dict(d, ok=True, error="", source=f"github.com/ipxe/shim {rel.get('tag_name')} "
f"{SHIM_ASSET} (dual-signed, 2011 + 2023)")
f"{SHIM_ASSET} (dual-signed: {' + '.join(d['ms_cas'])})")
def _record_stale(rec):
try:
schema = int(rec.get("schema") or 0)
except (TypeError, ValueError):
schema = 0
if schema < STAGED_SCHEMA:
return True
return any(isinstance(f, dict) and f.get("signers") is not None and "drivers" not in f
for f in rec["files"].values())
def staged(tag=None):
@@ -590,6 +786,12 @@ def staged(tag=None):
if tag is None or rec.get("tag") == tag:
# Where it is now, not the path recorded at staging time.
rec["folder"] = os.path.join(root, t)
if _record_stale(rec):
# Verified by older, weaker checks: show it, never install it.
rec["stale"] = True
for f in rec["files"].values():
if isinstance(f, dict):
f.update(ok=False, error="verified by an older version of this page; download it again")
out.append(rec)
out.sort(key=lambda r: r.get("staged") or "", reverse=True)
return out
@@ -751,6 +953,8 @@ def install(tag, names=DEFAULT_INSTALL):
rec = next(iter(staged(tag)), None)
if not rec:
raise ValueError(f"{tag} is not staged")
if rec.get("stale"):
raise ValueError(f"{tag} was verified by an older version of this page; download it again")
names = [n for n in MANAGED if n in names]
if not names:
raise ValueError("nothing selected")
@@ -763,13 +967,25 @@ def install(tag, names=DEFAULT_INSTALL):
def _install(tag, rec, names):
if _record_stale(rec):
raise ValueError(f"{tag} was verified by an older version of this page; download it again")
for n in names:
f = rec["files"].get(n) or {}
if not f.get("ok"):
raise ValueError(f"{n}: {f.get('error') or 'not staged'}")
path = os.path.join(rec["folder"], n)
if not os.path.isfile(path) or uc.file_hash(path) != f["sha256"]:
if not os.path.isfile(path) or uc.file_hash(path) != f.get("sha256"):
raise ValueError(f"{n}: staged copy changed since it was verified; download again")
# Re-read the file itself: the record says what was true at staging.
need = MANAGED[n][1]
if need:
now = _describe(path, os.stat(path)) # uncached: this gates going live
if now.get("sig_error") or not str(now.get("kind") or "").startswith(need):
raise ValueError(f"{n}: expected a signature under {NEED_LABEL[need]}, found "
f"{now.get('sig_error') or now.get('kind')}")
bad = risks(n, now)
if bad:
raise ValueError(f"{n}: {bad[0]}; refusing to install it")
if not any(n in names for n in SHIMS):
# iPXE alone must still be one the installed shim will start.
trusts = vendor_cert_cn(os.path.join(config.TFTP_ROOT, "ipxe-shim.efi"))
@@ -799,14 +1015,19 @@ def backups():
meta = uc.read_json(os.path.join(folder, "backup.json"), {})
meta = meta if isinstance(meta, dict) else {}
files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))]
infos = {f: describe(os.path.join(folder, f)) for f in files}
out.append({"id": b, "reason": meta.get("reason", ""), "at": meta.get("at", ""),
"files": files, "absent": meta.get("absent", []),
"versions": {f: version_of(os.path.join(folder, f)) for f in files}})
"info": infos,
"versions": {f: (infos[f] or {}).get("version", "") for f in files},
"warnings": [w for f in files for w in risks(f, infos[f])]})
return out
def rollback(backup_id):
"""Put the files of one backup back, after backing up what is there now."""
def rollback(backup_id, confirm=False):
"""Put the files of one backup back, after backing up what is there now.
A backup holding a native-USB ipxe.efi or a shim that is not dual-signed
is only restored with confirm=True."""
bid = os.path.basename(str(backup_id or ""))
folder = os.path.join(_dir(), "backups", bid)
if not bid or bid.startswith(".") or not os.path.isdir(folder):
@@ -814,6 +1035,10 @@ def rollback(backup_id):
files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))]
if not files:
raise ValueError("that backup holds no boot files")
warnings = [w for f in files
for w in risks(f, _describe(os.path.join(folder, f), os.stat(os.path.join(folder, f))))]
if warnings and not confirm:
raise ValueError("; ".join(warnings) + ". Tick the confirmation to restore it anyway")
with _Lock():
_backup(files, f"before rolling back to {bid}")
_place_all([(os.path.join(folder, f), f, uc.file_hash(os.path.join(folder, f)))