diff --git a/.github/workflows/parse-update-bins-for-review.yml b/.github/workflows/parse-update-bins-for-review.yml new file mode 100644 index 0000000..284b089 --- /dev/null +++ b/.github/workflows/parse-update-bins-for-review.yml @@ -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 diff --git a/scripts/parse_update_bins_for_review.py b/scripts/parse_update_bins_for_review.py new file mode 100644 index 0000000..1f9abcb --- /dev/null +++ b/scripts/parse_update_bins_for_review.py @@ -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()) diff --git a/scripts/test_parse_update_bins_for_review.py b/scripts/test_parse_update_bins_for_review.py new file mode 100644 index 0000000..e8264dc --- /dev/null +++ b/scripts/test_parse_update_bins_for_review.py @@ -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"]