From 52256df771e482c24ed9d71d63a2349acb889fcb Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Fri, 28 Aug 2026 15:53:14 -0500 Subject: [PATCH 1/6] feat(report): record SEV platform facts in the environment block Two hosts can be identical by OS, kernel, QEMU and OVMF version and still behave differently under SNP, so a failing certification result currently cannot be diagnosed from its own report. Record the facts that actually distinguish them: - SEV firmware version. This is the firmware in effect, which is not necessarily the one the BIOS supplied: the ccp driver loads /lib/firmware/amd/*.sbin at boot when present, so it tracks the host OS image rather than the platform. - Reported TCB, condensed to one line. - Host CPU model and CPUID family/model/stepping. Generation-dependent behaviour keys on family and model, both here and in the tooling this harness drives, so a failure that turns on processor generation is otherwise invisible in the result. - snphost and snpguest versions as found on the host. Detection is best-effort in the existing style: anything unavailable, or unreadable because the harness is not running as root, yields None and is simply omitted from the report. --- sev_verify/environment.py | 113 ++++++++++++++++++++++++++++++++++++++ sev_verify/output.py | 19 +++++++ 2 files changed, 132 insertions(+) diff --git a/sev_verify/environment.py b/sev_verify/environment.py index 277bd145..7588b736 100644 --- a/sev_verify/environment.py +++ b/sev_verify/environment.py @@ -75,6 +75,110 @@ def _get_ovmf_version(path: str) -> str | None: return None +def _run_tool(args: list[str], timeout: int = 5) -> str | None: + """Run *args* and return stripped stdout, or None on any failure.""" + resolved = shutil.which(args[0]) + if not resolved: + return None + try: + proc = subprocess.run( + [resolved, *args[1:]], + capture_output=True, text=True, timeout=timeout, + ) + except Exception: + return None + if proc.returncode != 0: + return None + out = proc.stdout.strip() + return out or None + + +def _get_tool_version(tool: str) -> str | None: + """Return `` --version`` output, e.g. ``snpguest 0.10.0``.""" + out = _run_tool([tool, "--version"]) + return out.splitlines()[0].strip() if out else None + + +def _get_sev_firmware_version() -> str | None: + """Return the SEV firmware version the PSP is currently running. + + This is the firmware actually in effect, which is not necessarily the one + the BIOS supplied: the ``ccp`` driver loads ``/lib/firmware/amd/*.sbin`` at + boot when present, so it varies with the host OS image rather than with the + platform. Requires root; returns None otherwise. + """ + out = _run_tool(["snphost", "show", "version"]) + return out.splitlines()[0].strip() if out else None + + +def _get_reported_tcb() -> str | None: + """Return the reported TCB as a single line, e.g. ``bootloader=9 tee=0 …``. + + ``snphost show tcb`` prints a multi-line block; it is condensed here so the + value fits on one line of the environment report. + """ + out = _run_tool(["snphost", "show", "tcb"]) + if not out: + return None + wanted = { + "boot loader": "bootloader", + "tee": "tee", + "snp": "snp", + "microcode": "microcode", + "fmc": "fmc", + } + found: dict[str, str] = {} + for line in out.splitlines(): + key, sep, value = line.partition(":") + if not sep: + continue + short = wanted.get(key.strip().lower()) + if short and value.strip(): + found[short] = value.strip() + if not found: + return None + order = ("bootloader", "tee", "snp", "microcode", "fmc") + return " ".join(f"{k}={found[k]}" for k in order if k in found) + + +def _get_host_cpu() -> dict[str, str | None]: + """Return the host CPU model name and CPUID family/model/stepping. + + Reported because generation-dependent behaviour keys on family and model — + both in this harness and in the tooling it drives — so a failure that turns + on the processor generation is otherwise undiagnosable from a result alone. + """ + fields: dict[str, str] = {} + try: + with open("/proc/cpuinfo") as fh: + for line in fh: + key, sep, value = line.partition(":") + if not sep: + continue + key = key.strip().lower() + if key in ("model name", "cpu family", "model", "stepping"): + fields.setdefault(key, value.strip()) + if len(fields) == 4: + break + except Exception: + return {"host_cpu_model": None, "host_cpu_id": None} + + def _hex(name: str) -> str | None: + raw = fields.get(name) + try: + return f"0x{int(raw):x}" + except (TypeError, ValueError): + return None + + family, model, stepping = _hex("cpu family"), _hex("model"), _hex("stepping") + cpu_id = None + if family and model: + cpu_id = f"family {family} model {model}" + if stepping: + cpu_id += f" stepping {stepping}" + return {"host_cpu_model": fields.get("model name"), "host_cpu_id": cpu_id} + + def detect_environment( *, qemu_binary: str = "qemu-system-x86_64", @@ -85,6 +189,7 @@ def detect_environment( All detection is best-effort: failures produce ``None`` values. """ host_os = get_host_os_info() + host_cpu = _get_host_cpu() return { "qemu_version": _get_qemu_version(qemu_binary), "qemu_binary": qemu_binary, @@ -94,4 +199,12 @@ def detect_environment( "host_os_name": host_os.get("host_os_name"), "host_os_release": host_os.get("host_os_release"), "host_os_pretty_name": host_os.get("host_os_pretty_name"), + # SEV-specific facts. These are what distinguish two hosts that look + # identical by OS and QEMU version but behave differently under SNP. + "sev_firmware_version": _get_sev_firmware_version(), + "reported_tcb": _get_reported_tcb(), + "snphost_version": _get_tool_version("snphost"), + "snpguest_version": _get_tool_version("snpguest"), + "host_cpu_model": host_cpu["host_cpu_model"], + "host_cpu_id": host_cpu["host_cpu_id"], } diff --git a/sev_verify/output.py b/sev_verify/output.py index d44896a9..33c540b4 100644 --- a/sev_verify/output.py +++ b/sev_verify/output.py @@ -176,6 +176,25 @@ def write_markdown( env_lines.append(f"- **OVMF:** {environment['ovmf_version']}") elif environment.get("ovmf_path"): env_lines.append(f"- **OVMF:** {environment['ovmf_path']}") + if environment.get("host_cpu_model"): + cpu = environment["host_cpu_model"] + if environment.get("host_cpu_id"): + cpu = f"{cpu} ({environment['host_cpu_id']})" + env_lines.append(f"- **Host CPU:** {cpu}") + elif environment.get("host_cpu_id"): + env_lines.append(f"- **Host CPU:** {environment['host_cpu_id']}") + if environment.get("sev_firmware_version"): + env_lines.append( + f"- **SEV firmware:** {environment['sev_firmware_version']}" + ) + if environment.get("reported_tcb"): + env_lines.append(f"- **Reported TCB:** {environment['reported_tcb']}") + if environment.get("snphost_version"): + env_lines.append(f"- **snphost:** {environment['snphost_version']}") + if environment.get("snpguest_version"): + env_lines.append( + f"- **snpguest (host):** {environment['snpguest_version']}" + ) if env_lines: w("## Environment") w("") From aaab3fff0ef4585d69a91cb42e141a8823440933 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Fri, 28 Aug 2026 16:05:48 -0500 Subject: [PATCH 2/6] feat(report): record the guest's snpguest version alongside the host's The host and guest each carry their own snpguest, installed independently. When a guest-side attestation step fails there is currently nothing in the result identifying which build produced the failure, so the first question asked of such a report cannot be answered from it. Add get_guest_snpguest_version() next to the existing guest OS probe, and introduce update_environment_with_guest_info() to gather both on the first successful launch. The existing update_environment_with_guest_os() keeps its narrower meaning rather than quietly growing a second responsibility. Best-effort as before: no guest, no vsock agent, or no snpguest on the guest yields None and the line is omitted. --- sev_verify/cli.py | 6 +++--- sev_verify/os_info.py | 36 ++++++++++++++++++++++++++++++++++++ sev_verify/output.py | 4 ++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/sev_verify/cli.py b/sev_verify/cli.py index 81db41d5..c30bf4c3 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -10,7 +10,7 @@ from pathlib import Path from .environment import detect_environment -from .os_info import update_environment_with_guest_os +from .os_info import update_environment_with_guest_info from .models import ( CertificationDefinition, CertificationResult, @@ -407,7 +407,7 @@ def execute_test( sr, new_launch = run_vm_launch_step(step, profile) launch = new_launch if launch is not None and launch.ok and environment is not None: - update_environment_with_guest_os(environment, launch.profile) + update_environment_with_guest_info(environment, launch.profile) elif step.kind == "vm_stop": if launch is None: sr = StepResult( @@ -433,7 +433,7 @@ def execute_test( if launch is None: launch = profile.vm_launch() if launch.ok and environment is not None: - update_environment_with_guest_os(environment, launch.profile) + update_environment_with_guest_info(environment, launch.profile) if not launch.ok: sr = StepResult( step=step, diff --git a/sev_verify/os_info.py b/sev_verify/os_info.py index e2e6f65e..4111573a 100644 --- a/sev_verify/os_info.py +++ b/sev_verify/os_info.py @@ -109,6 +109,27 @@ def format_os_info(os_info: dict[str, str | None]) -> str | None: return name or release or None +def get_guest_snpguest_version(profile: "VMProfile") -> str | None: + """Read the guest's snpguest version via vsock. + + Reported separately from the host's because the two are installed + independently, and a guest-side attestation failure cannot be attributed + without knowing which build produced it. + + Requires an active guest with vsock agent; returns None otherwise. + """ + from .guest_vsock import run_guest_command, GuestVsockError + + try: + result = run_guest_command(profile, "snpguest --version", timeout=10) + except GuestVsockError: + return None + if result.exit_code != 0: + return None + first_line = result.stdout.strip().splitlines() + return first_line[0].strip() if first_line else None + + def update_environment_with_guest_os( environment: dict[str, str | None], profile: "VMProfile", @@ -126,3 +147,18 @@ def update_environment_with_guest_os( environment["guest_os_release"] = guest_info.get("guest_os_release") environment["guest_os_pretty_name"] = guest_info.get("guest_os_pretty_name") environment["guest_os_id"] = guest_info.get("guest_os_id") + + +def update_environment_with_guest_info( + environment: dict[str, str | None], + profile: "VMProfile", +) -> None: + """Update environment in-place with everything readable from the guest. + + Currently the guest OS identity and the guest's snpguest version. Both are + best-effort and are collected once, on the first successful launch. + """ + already_done = "guest_snpguest_version" in environment + update_environment_with_guest_os(environment, profile) + if not already_done: + environment["guest_snpguest_version"] = get_guest_snpguest_version(profile) diff --git a/sev_verify/output.py b/sev_verify/output.py index 33c540b4..8d39596a 100644 --- a/sev_verify/output.py +++ b/sev_verify/output.py @@ -195,6 +195,10 @@ def write_markdown( env_lines.append( f"- **snpguest (host):** {environment['snpguest_version']}" ) + if environment.get("guest_snpguest_version"): + env_lines.append( + f"- **snpguest (guest):** {environment['guest_snpguest_version']}" + ) if env_lines: w("## Environment") w("") From 56e66a3051a12c210427a2d59b2ad58457ee5f99 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Fri, 28 Aug 2026 16:07:19 -0500 Subject: [PATCH 3/6] feat(build): record which snpguest release each image was built with The snpguest build module resolves "latest" from the GitHub API at build time unless SNPGUEST_TAG is set, so two builds of identical source can install different tooling and nothing in the resulting image, or in the certification result it produces, says which one it got. Write the resolved tag to /usr/local/share/sev-certify/snpguest-tag during the build and report it alongside the detected versions. Absent on hosts not built by this project, in which case the line is simply omitted. This records the tag rather than pinning it; pinning is a policy decision left to SNPGUEST_TAG. --- modules/build/common/snpguest/mkosi.build | 7 +++++++ sev_verify/environment.py | 19 +++++++++++++++++++ sev_verify/output.py | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/modules/build/common/snpguest/mkosi.build b/modules/build/common/snpguest/mkosi.build index 8b2eca40..8085e01e 100755 --- a/modules/build/common/snpguest/mkosi.build +++ b/modules/build/common/snpguest/mkosi.build @@ -8,3 +8,10 @@ LATEST_TAG="${SNPGUEST_TAG:-$(curl -s https://api.github.com/repos/virtee/snpgue # Download and install into DESTDIR with correct permissions curl -fsSL "https://github.com/virtee/snpguest/releases/download/${LATEST_TAG}/snpguest" \ | install -D -m 0755 /dev/stdin "${DESTDIR}/usr/local/bin/snpguest" + +# Record which tag was actually installed. Unless SNPGUEST_TAG is set, the +# line above resolves "latest" at build time, so two builds of identical +# source can contain different tooling with nothing to say so. sev_verify +# reads this file and reports it, making each image self-describing. +printf '%s\n' "${LATEST_TAG}" \ + | install -D -m 0644 /dev/stdin "${DESTDIR}/usr/local/share/sev-certify/snpguest-tag" diff --git a/sev_verify/environment.py b/sev_verify/environment.py index 7588b736..885afe54 100644 --- a/sev_verify/environment.py +++ b/sev_verify/environment.py @@ -141,6 +141,24 @@ def _get_reported_tcb() -> str | None: return " ".join(f"{k}={found[k]}" for k in order if k in found) +#: Written by modules/build/common/snpguest/mkosi.build at image build time. +SNPGUEST_TAG_FILE = "/usr/local/share/sev-certify/snpguest-tag" + + +def _get_snpguest_tag() -> str | None: + """Return the snpguest release tag this image was built with. + + The image build resolves "latest" at build time unless SNPGUEST_TAG is + pinned, so the installed tooling is not implied by the source revision. + Absent on hosts that were not built by this project. + """ + try: + with open(SNPGUEST_TAG_FILE) as fh: + return fh.read().strip() or None + except Exception: + return None + + def _get_host_cpu() -> dict[str, str | None]: """Return the host CPU model name and CPUID family/model/stepping. @@ -205,6 +223,7 @@ def detect_environment( "reported_tcb": _get_reported_tcb(), "snphost_version": _get_tool_version("snphost"), "snpguest_version": _get_tool_version("snpguest"), + "snpguest_tag": _get_snpguest_tag(), "host_cpu_model": host_cpu["host_cpu_model"], "host_cpu_id": host_cpu["host_cpu_id"], } diff --git a/sev_verify/output.py b/sev_verify/output.py index 8d39596a..669c73f1 100644 --- a/sev_verify/output.py +++ b/sev_verify/output.py @@ -195,6 +195,10 @@ def write_markdown( env_lines.append( f"- **snpguest (host):** {environment['snpguest_version']}" ) + if environment.get("snpguest_tag"): + env_lines.append( + f"- **snpguest build tag:** {environment['snpguest_tag']}" + ) if environment.get("guest_snpguest_version"): env_lines.append( f"- **snpguest (guest):** {environment['guest_snpguest_version']}" From 81e40f4d9560f72df39a72289783e918e4db7cff Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Sat, 29 Aug 2026 12:06:38 -0500 Subject: [PATCH 4/6] feat(report): record platform identifier and how the report was framed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two further facts that distinguish otherwise-identical hosts. Platform identifier, from snphost show identifier, so results can be tied to a specific machine rather than to a machine description. A one-line summary of any attestation report the run produced: its version, its CPUID family/model/stepping bytes, and whether CHIP_ID came back zeroed. These decide how a report is parsed rather than what it attests — consumers select a TCB layout from the processor generation implied by those CPUID bytes, and reject the report outright when they do not recognise it, before reading any of its contents. A result that fails there currently says only that it failed. CHIP_ID is included because MASK_CHIP_ID zeroes it and snphost show offers no way to read that setting back, so a zeroed CHIP_ID is the only externally visible sign that masking is in effect. The mask is set by snphost config set and cleared by snphost config reset, which the config/commit test exercises directly. The report is located by mtime against the start of the run so one left over from an earlier run is not described as though this run produced it. --- sev_verify/cli.py | 11 +++++- sev_verify/environment.py | 75 +++++++++++++++++++++++++++++++++++++++ sev_verify/output.py | 8 +++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/sev_verify/cli.py b/sev_verify/cli.py index c30bf4c3..a2367035 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from pathlib import Path -from .environment import detect_environment +from .environment import detect_environment, find_recent_report, summarize_report from .os_info import update_environment_with_guest_info from .models import ( CertificationDefinition, @@ -711,6 +711,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Warning: no tests match level filter(s) {levels!r} " f"in certification {cert.version}", file=sys.stderr) continue + run_started = time.time() cr = execute_certification( cert, guest_path, @@ -720,6 +721,14 @@ def main(argv: list[str] | None = None) -> int: environment=environment, ) cert_results.append(cr) + + # Describe a report this run produced, if any. Done here rather than in + # a test because it is environment, not a result: which report version + # and CPUID the firmware emitted determines how every consumer parses it. + if environment is not None and not environment.get("report_summary"): + report_path = find_recent_report(args.artifacts_dir, run_started) + if report_path is not None: + environment["report_summary"] = summarize_report(report_path) total_tests += len(cr.test_results) total_passed += sum(1 for tr in cr.test_results if tr.result == "pass") diff --git a/sev_verify/environment.py b/sev_verify/environment.py index 885afe54..5ed25b3f 100644 --- a/sev_verify/environment.py +++ b/sev_verify/environment.py @@ -7,6 +7,8 @@ import shutil import subprocess +from pathlib import Path + from .os_info import get_host_os_info @@ -141,6 +143,78 @@ def _get_reported_tcb() -> str | None: return " ".join(f"{k}={found[k]}" for k in order if k in found) +def _get_platform_identifier() -> str | None: + """Return the platform identifier from ``snphost show identifier``.""" + out = _run_tool(["snphost", "show", "identifier"]) + return out.splitlines()[0].strip() if out else None + + +# Offsets into the SNP attestation report, per the ABI specification (56860). +# CPUID_FAM_ID / MOD_ID / STEP exist only from report version 3 onwards. +_REPORT_CPUID_FAM = 0x188 +_REPORT_CPUID_MOD = 0x189 +_REPORT_CPUID_STEP = 0x18A +_REPORT_CHIP_ID = 0x1A0 +_REPORT_CHIP_ID_LEN = 64 +_REPORT_MIN_LEN = _REPORT_CHIP_ID + _REPORT_CHIP_ID_LEN + + +def summarize_report(path: str | os.PathLike[str]) -> str | None: + """Summarize a report: version, CPUID triple, and whether CHIP_ID is zeroed. + + These are the fields that decide how a report is *parsed*, as distinct from + what it attests. Consumers pick a TCB layout from the processor generation, + which they derive from the CPUID bytes, so a report carrying unexpected + values there is rejected before any of its contents are read. + + CHIP_ID is reported as zeroed or present because ``MASK_CHIP_ID`` zeroes it + and ``snphost show`` offers no way to read that setting back: a zeroed + CHIP_ID is the only externally visible sign that masking is in effect. + """ + try: + with open(path, "rb") as fh: + data = fh.read() + except Exception: + return None + if len(data) < _REPORT_MIN_LEN: + return None + + version = int.from_bytes(data[0:4], "little") + parts = [f"version={version}"] + if version >= 3: + parts.append( + "cpuid=0x{:02x}/0x{:02x}/0x{:02x}".format( + data[_REPORT_CPUID_FAM], + data[_REPORT_CPUID_MOD], + data[_REPORT_CPUID_STEP], + ) + ) + chip_id = data[_REPORT_CHIP_ID:_REPORT_CHIP_ID + _REPORT_CHIP_ID_LEN] + parts.append("chip_id=" + ("zeroed" if not any(chip_id) else "present")) + return " ".join(parts) + + +def find_recent_report( + artifacts_root: str | os.PathLike[str], + since: float, +) -> "Path | None": + """Return the newest ``report.bin`` under *artifacts_root* newer than *since*. + + The mtime bound stops a report left behind by an earlier run being described + as though this run had produced it. + """ + try: + candidates = [ + p for p in Path(artifacts_root).rglob("report.bin") + if p.stat().st_mtime >= since + ] + except Exception: + return None + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) + + #: Written by modules/build/common/snpguest/mkosi.build at image build time. SNPGUEST_TAG_FILE = "/usr/local/share/sev-certify/snpguest-tag" @@ -224,6 +298,7 @@ def detect_environment( "snphost_version": _get_tool_version("snphost"), "snpguest_version": _get_tool_version("snpguest"), "snpguest_tag": _get_snpguest_tag(), + "platform_identifier": _get_platform_identifier(), "host_cpu_model": host_cpu["host_cpu_model"], "host_cpu_id": host_cpu["host_cpu_id"], } diff --git a/sev_verify/output.py b/sev_verify/output.py index 669c73f1..d7141fd6 100644 --- a/sev_verify/output.py +++ b/sev_verify/output.py @@ -187,6 +187,14 @@ def write_markdown( env_lines.append( f"- **SEV firmware:** {environment['sev_firmware_version']}" ) + if environment.get("platform_identifier"): + env_lines.append( + f"- **Platform ID:** {environment['platform_identifier']}" + ) + if environment.get("report_summary"): + env_lines.append( + f"- **Attestation report:** {environment['report_summary']}" + ) if environment.get("reported_tcb"): env_lines.append(f"- **Reported TCB:** {environment['reported_tcb']}") if environment.get("snphost_version"): From 7a03831d737dc0723f424e134efc96e4fac7db01 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Sat, 29 Aug 2026 17:07:30 -0500 Subject: [PATCH 5/6] feat(report): capture the attestation report via configfs-TSM as well snpguest declines to write a report whose processor generation it cannot resolve, so precisely when a run most needs the report it produces no artifact at all: the one thing that would explain the failure is destroyed by the failure. Capture it a second way. The kernel's configfs-TSM interface is vendor neutral and returns the raw report without interpreting it, so it yields bytes in cases where the snpguest path yields nothing: mkdir /sys/kernel/config/tsm/report/sev_verify head -c 64 /dev/urandom > .../inblob cat .../outblob Both steps are typed "info", so a guest whose kernel lacks configfs-TSM support costs nothing and the certification result is unaffected either way. The pulled artifact is named tsm-report.bin and joins report.bin as a source the environment summary will describe, whichever is newer. Verified against a Rocky 10.2 guest (kernel 6.12): 1184 bytes, parsing as a version 3 report. The two reports differ in their bytes because each carries its own nonce and signature. --- .../c3_0/c3_0_0_0/attestation_test.py | 25 +++++++++++++++++++ sev_verify/environment.py | 19 ++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py b/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py index 4dc18888..45e950be 100644 --- a/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py +++ b/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py @@ -159,6 +159,31 @@ def steps() -> list[BaseStep]: host_dest="request.bin", timeout=120, ), + # Capture the report a second way, through the kernel's vendor-neutral + # configfs-TSM interface, which returns the raw bytes without parsing + # them. This is diagnostic rather than a check: when the snpguest steps + # above fail because the report cannot be classified, they leave no + # artifact behind, and the report is then the one thing needed to find + # out why. Typed "info" so a kernel without configfs-TSM support costs + # nothing. + Step.for_guest( + name="Capture report via configfs-TSM", + type="info", + command=( + "D=/sys/kernel/config/tsm/report/sev_verify; " + "rmdir $D 2>/dev/null; mkdir $D || exit 1; " + "head -c 64 /dev/urandom > $D/inblob && cat $D/outblob > /tmp/tsm-report.bin; " + "rc=$?; rmdir $D 2>/dev/null; exit $rc" + ), + timeout=60, + ), + Step.for_guest_pull( + name="Pull configfs-TSM report", + type="info", + guest_src="/tmp/tsm-report.bin", + host_dest="tsm-report.bin", + timeout=120, + ), Step.for_host( name="Fetch certificate chain from kds", type="setup", diff --git a/sev_verify/environment.py b/sev_verify/environment.py index 5ed25b3f..6a47f2cc 100644 --- a/sev_verify/environment.py +++ b/sev_verify/environment.py @@ -194,20 +194,29 @@ def summarize_report(path: str | os.PathLike[str]) -> str | None: return " ".join(parts) +#: Report artifacts to describe, in no particular preference order — the newest +#: wins. ``tsm-report.bin`` comes from the kernel's configfs-TSM interface and +#: exists even when the snpguest-produced ``report.bin`` does not, since that +#: command declines to write a report it cannot classify. +REPORT_ARTIFACT_NAMES = ("report.bin", "tsm-report.bin") + + def find_recent_report( artifacts_root: str | os.PathLike[str], since: float, ) -> "Path | None": - """Return the newest ``report.bin`` under *artifacts_root* newer than *since*. + """Return the newest report artifact under *artifacts_root* newer than *since*. The mtime bound stops a report left behind by an earlier run being described as though this run had produced it. """ + candidates: list[Path] = [] try: - candidates = [ - p for p in Path(artifacts_root).rglob("report.bin") - if p.stat().st_mtime >= since - ] + for name in REPORT_ARTIFACT_NAMES: + candidates += [ + p for p in Path(artifacts_root).rglob(name) + if p.stat().st_mtime >= since + ] except Exception: return None if not candidates: From 078deb280317df1c5734e12f683a4b523af11c95 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Sun, 30 Aug 2026 10:50:06 -0500 Subject: [PATCH 6/6] feat(report): distinguish BIOS-supplied firmware from OS-loaded firmware The reported SEV firmware version is the one currently in effect, but says nothing about where it came from. Two versions are in play at different times, and they govern different things: the BIOS-supplied firmware runs at processor init and decides platform-level behaviour, while the ccp driver may replace it at boot from /lib/firmware/amd/*.sbin, after which every firmware command answers from the replacement. Whether that replacement happens is a property of the host OS image. A result that reports only the resulting version cannot be read correctly, because the same version string can mean "this platform's own firmware" or "whatever this distro happened to ship". Record two things: - The SEV firmware blobs available to the driver, identified by name and digest. Their absence is stated explicitly rather than left as a missing field, because absence is the informative case: it means no override occurred and the platform is running BIOS-supplied firmware. - The driver's own SEV lines from the kernel log, verbatim. These carry the firmware build number that snphost show version omits and report whether an update was applied at boot. They are quoted rather than parsed: the wording varies across kernels, and a parser would silently produce nothing on the versions it did not anticipate. Digests are recorded because two builds of a blob share a filename and may share an API version while differing in behaviour. --- sev_verify/environment.py | 60 +++++++++++++++++++++++++++++++++++++++ sev_verify/output.py | 8 ++++++ 2 files changed, 68 insertions(+) diff --git a/sev_verify/environment.py b/sev_verify/environment.py index 6a47f2cc..2935c26c 100644 --- a/sev_verify/environment.py +++ b/sev_verify/environment.py @@ -2,8 +2,11 @@ from __future__ import annotations +import glob +import hashlib import os import platform +import re import shutil import subprocess @@ -143,6 +146,61 @@ def _get_reported_tcb() -> str | None: return " ".join(f"{k}={found[k]}" for k in order if k in found) +#: Blobs the ccp driver loads at boot, overriding BIOS-supplied firmware. +SEV_FIRMWARE_GLOB = "/lib/firmware/amd/amd_sev_*.sbin" + + +def _get_sev_firmware_source() -> str | None: + """Report which SEV firmware blobs are available for the driver to load. + + The ``ccp`` driver loads these at boot when present, replacing the firmware + the BIOS supplied; when none is present the platform keeps running the + BIOS-supplied firmware for the life of the boot. The absence is therefore + as meaningful as the presence, and is reported explicitly rather than as a + missing field: it says the runtime firmware is the platform's own, not the + host OS's choice, which changes how every other firmware-derived value in + this report should be read. + + Identified by digest as well as name, since two builds of the same blob + share a filename and may share an API version while differing in behaviour. + """ + paths = sorted(glob.glob(SEV_FIRMWARE_GLOB)) + if not paths: + return "none present — BIOS-supplied firmware retained" + parts: list[str] = [] + for path in paths: + try: + with open(path, "rb") as fh: + blob = fh.read() + except OSError: + continue + digest = hashlib.sha256(blob).hexdigest()[:12] + parts.append(f"{os.path.basename(path)} ({len(blob)}B sha256:{digest})") + return ", ".join(parts) or None + + +def _get_sev_firmware_log(max_lines: int = 4, max_chars: int = 400) -> str | None: + """Return the driver's own SEV lines from the kernel log, verbatim. + + Quoted rather than parsed. These lines carry the firmware build number that + ``snphost show version`` omits, and report whether an update was applied at + boot — but their wording varies across kernels, so a parser would silently + yield nothing on the versions it did not anticipate. Recording what the + driver actually said keeps the field useful even then. + + Requires a readable kernel log; returns None otherwise. + """ + out = _run_tool(["dmesg"], timeout=10) + if not out: + return None + matcher = re.compile(r"\bccp\b|SEV[- ]SNP API|SEV API|sev firmware", re.IGNORECASE) + lines = [ln.strip() for ln in out.splitlines() if matcher.search(ln)] + if not lines: + return None + joined = " | ".join(lines[-max_lines:]) + return joined[:max_chars] + ("…" if len(joined) > max_chars else "") + + def _get_platform_identifier() -> str | None: """Return the platform identifier from ``snphost show identifier``.""" out = _run_tool(["snphost", "show", "identifier"]) @@ -303,6 +361,8 @@ def detect_environment( # SEV-specific facts. These are what distinguish two hosts that look # identical by OS and QEMU version but behave differently under SNP. "sev_firmware_version": _get_sev_firmware_version(), + "sev_firmware_source": _get_sev_firmware_source(), + "sev_firmware_log": _get_sev_firmware_log(), "reported_tcb": _get_reported_tcb(), "snphost_version": _get_tool_version("snphost"), "snpguest_version": _get_tool_version("snpguest"), diff --git a/sev_verify/output.py b/sev_verify/output.py index d7141fd6..7d861a29 100644 --- a/sev_verify/output.py +++ b/sev_verify/output.py @@ -195,6 +195,14 @@ def write_markdown( env_lines.append( f"- **Attestation report:** {environment['report_summary']}" ) + if environment.get("sev_firmware_source"): + env_lines.append( + f"- **SEV firmware blobs:** {environment['sev_firmware_source']}" + ) + if environment.get("sev_firmware_log"): + env_lines.append( + f"- **SEV driver log:** `{environment['sev_firmware_log']}`" + ) if environment.get("reported_tcb"): env_lines.append(f"- **Reported TCB:** {environment['reported_tcb']}") if environment.get("snphost_version"):