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:
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user