Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/parse-update-bins-for-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# This workflow parses changed secure boot update binaries into JSON receipts
# so pull request reviewers can inspect human-readable content.
#
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: BSD-2-Clause-Patent
name: Parse Updated EFI Binaries

on:
pull_request:
branches: [ "main" ]
paths:
- 'PostSignedObjects/**/*.bin'
- 'PreSignedObjects/**/*.bin'

permissions:
contents: read

jobs:
parse-update-bins:
name: Parse Changed Update Bins
runs-on: ubuntu-latest

steps:
- name: Checkout PR
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.12
cache: 'pip'
cache-dependency-path: pip-requirements.txt

- name: Install Pip Dependencies
run: |
python -m pip install --upgrade pip
pip install -r pip-requirements.txt

- name: Get Changed Bin Files
id: changed-files
run: |
git fetch origin ${{ github.base_ref }}
CHANGED_FILES=$(git diff --name-only --diff-filter=AMR origin/${{ github.base_ref }}...HEAD | grep -E '^(PostSignedObjects|PreSignedObjects)/.*\.bin$' || echo "")

if [ -z "$CHANGED_FILES" ]; then
echo "No matching .bin files changed."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" > changed_bin_files.txt
echo "Changed files:"
cat changed_bin_files.txt
fi

- name: Parse Changed Bin Files
if: steps.changed-files.outputs.has_changes == 'true'
run: |
python scripts/parse_update_bins_for_review.py \
--file-list changed_bin_files.txt \
--output-dir parsed-bin-receipts \
--repo-root .

- name: Upload Parsed Receipts
if: steps.changed-files.outputs.has_changes == 'true' && always()
uses: actions/upload-artifact@v7
with:
name: parsed-update-bin-receipts
path: parsed-bin-receipts/
retention-days: 30
147 changes: 147 additions & 0 deletions scripts/parse_update_bins_for_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# @file
#
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: BSD-2-Clause-Patent
##
"""Parse changed secure boot update binaries into JSON receipts for PR review."""

import argparse
import json
import os
import pathlib
from typing import Any

from utility_functions import get_signed_payload_receipt, get_unsigned_payload_receipt


def _receipt_output_path(
input_file: pathlib.Path, output_dir: pathlib.Path, repo_root: pathlib.Path
) -> pathlib.Path:
"""Build the JSON output path for an input binary."""
relative_file = input_file
try:
relative_file = input_file.resolve().relative_to(repo_root.resolve())
except ValueError:
relative_file = pathlib.Path(input_file.name)

return output_dir / relative_file.with_suffix(f"{relative_file.suffix}.json")


def parse_update_bin(input_file: pathlib.Path) -> tuple[dict[str, Any], str]:
"""Parse an update binary as signed auth var first, then as unsigned signature database."""
try:
return get_signed_payload_receipt(input_file), "signed"
except Exception as signed_error:
try:
return get_unsigned_payload_receipt(input_file), "unsigned"
except Exception as unsigned_error:
raise ValueError(
f"{input_file} is not a recognized signed or unsigned update payload "
f"(signed error: {signed_error}; unsigned error: {unsigned_error})"
) from unsigned_error


def parse_files(
files: list[pathlib.Path], output_dir: pathlib.Path, repo_root: pathlib.Path
) -> dict[str, list[dict[str, str]]]:
"""Parse each file and emit receipts under the output directory."""
summary: dict[str, list[dict[str, str]]] = {"parsed": [], "skipped": []}
output_dir.mkdir(parents=True, exist_ok=True)

for file in files:
if not file.is_file():
summary["skipped"].append({"file": str(file), "reason": "File not found"})
continue

try:
receipt, mode = parse_update_bin(file)
output_file = _receipt_output_path(file, output_dir, repo_root)
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(f"{json.dumps(receipt, indent=2)}\n", encoding="utf-8")
summary["parsed"].append({"file": str(file), "mode": mode, "output": str(output_file)})
except ValueError as error:
summary["skipped"].append({"file": str(file), "reason": str(error)})

return summary


def _print_summary(summary: dict[str, list[dict[str, str]]]) -> None:
"""Print and write a workflow summary of parsed files."""
parsed_count = len(summary["parsed"])
skipped_count = len(summary["skipped"])

lines = [
"## Parsed EFI Update Binaries",
"",
f"- Parsed: {parsed_count}",
f"- Skipped: {skipped_count}",
"",
]

if summary["parsed"]:
lines.append("### Parsed Files")
for item in summary["parsed"]:
lines.append(f"- `{item['file']}` ({item['mode']}) -> `{item['output']}`")
lines.append("")

if summary["skipped"]:
lines.append("### Skipped Files")
for item in summary["skipped"]:
lines.append(f"- `{item['file']}`: {item['reason']}")
lines.append("")

output = "\n".join(lines)
print(output)

step_summary_file = os.getenv("GITHUB_STEP_SUMMARY")
if step_summary_file:
with open(step_summary_file, "a", encoding="utf-8") as summary_file:
summary_file.write(f"{output}\n")


def _read_file_list(file_list: pathlib.Path) -> list[pathlib.Path]:
"""Read newline-separated file paths from a file."""
return [pathlib.Path(line.strip()) for line in file_list.read_text(encoding="utf-8").splitlines() if line.strip()]


def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Parse signed/unsigned secure boot update binaries into JSON receipts."
)
parser.add_argument("files", nargs="*", type=pathlib.Path, help="Binary files to parse.")
parser.add_argument("--file-list", type=pathlib.Path, help="Path to a newline-separated list of binary files.")
parser.add_argument(
"--output-dir",
type=pathlib.Path,
required=True,
help="Directory where receipt JSON files are written.",
)
parser.add_argument(
"--repo-root",
type=pathlib.Path,
default=pathlib.Path("."),
help="Repository root used for output paths.",
)
args = parser.parse_args()

if not args.files and not args.file_list:
parser.error("Provide at least one input file or --file-list.")

return args


def main() -> int:
"""Entry point for parsing changed update binaries."""
args = parse_args()
files = list(args.files)
if args.file_list:
files.extend(_read_file_list(args.file_list))

summary = parse_files(files, args.output_dir, args.repo_root)
_print_summary(summary)
return 0


if __name__ == "__main__":
raise SystemExit(main())
96 changes: 96 additions & 0 deletions scripts/test_parse_update_bins_for_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# @file
#
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: BSD-2-Clause-Patent
##
"""Tests for parsing update binaries for PR review artifacts."""

import json
import pathlib

import parse_update_bins_for_review
from _pytest.monkeypatch import MonkeyPatch


def test_parse_files_prefers_signed_format(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
"""It should parse signed payloads first and emit a JSON receipt file."""
repo_root = tmp_path
input_file = repo_root / "PostSignedObjects" / "KEK" / "x64" / "KEKUpdate.bin"
input_file.parent.mkdir(parents=True, exist_ok=True)
input_file.write_bytes(b"test")

monkeypatch.setattr(
parse_update_bins_for_review,
"get_signed_payload_receipt",
lambda _: {"fileName": "KEKUpdate.bin", "signatureDatabase": []},
)
monkeypatch.setattr(
parse_update_bins_for_review,
"get_unsigned_payload_receipt",
lambda _: (_ for _ in ()).throw(AssertionError("unsigned parser should not be called")),
)

output_dir = tmp_path / "receipts"
summary = parse_update_bins_for_review.parse_files([input_file], output_dir, repo_root)

assert len(summary["parsed"]) == 1
assert summary["parsed"][0]["mode"] == "signed"
assert summary["skipped"] == []

receipt_file = output_dir / "PostSignedObjects" / "KEK" / "x64" / "KEKUpdate.bin.json"
assert receipt_file.exists()
assert json.loads(receipt_file.read_text(encoding="utf-8"))["fileName"] == "KEKUpdate.bin"


def test_parse_files_falls_back_to_unsigned_format(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
"""It should fall back to unsigned parsing if signed parsing fails."""
repo_root = tmp_path
input_file = repo_root / "PostSignedObjects" / "DB" / "x64" / "DBUpdate.bin"
input_file.parent.mkdir(parents=True, exist_ok=True)
input_file.write_bytes(b"test")

monkeypatch.setattr(
parse_update_bins_for_review,
"get_signed_payload_receipt",
lambda _: (_ for _ in ()).throw(ValueError("not signed")),
)
monkeypatch.setattr(
parse_update_bins_for_review,
"get_unsigned_payload_receipt",
lambda _: {"fileName": "DBUpdate.bin", "signatureDatabase": []},
)

output_dir = tmp_path / "receipts"
summary = parse_update_bins_for_review.parse_files([input_file], output_dir, repo_root)

assert len(summary["parsed"]) == 1
assert summary["parsed"][0]["mode"] == "unsigned"
assert summary["skipped"] == []


def test_parse_files_marks_unrecognized_payloads_as_skipped(
tmp_path: pathlib.Path, monkeypatch: MonkeyPatch
) -> None:
"""It should skip files that are neither signed nor unsigned update payloads."""
repo_root = tmp_path
input_file = repo_root / "PostSignedObjects" / "Optional" / "other.bin"
input_file.parent.mkdir(parents=True, exist_ok=True)
input_file.write_bytes(b"test")

monkeypatch.setattr(
parse_update_bins_for_review,
"get_signed_payload_receipt",
lambda _: (_ for _ in ()).throw(ValueError("not signed")),
)
monkeypatch.setattr(
parse_update_bins_for_review,
"get_unsigned_payload_receipt",
lambda _: (_ for _ in ()).throw(ValueError("not unsigned")),
)

output_dir = tmp_path / "receipts"
summary = parse_update_bins_for_review.parse_files([input_file], output_dir, repo_root)

assert summary["parsed"] == []
assert len(summary["skipped"]) == 1
assert "not a recognized signed or unsigned update payload" in summary["skipped"][0]["reason"]
Loading