#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Generate deterministic Phase-0.7 static ELF evidence without execution.""" from __future__ import annotations import argparse import hashlib import json import re import subprocess from pathlib import Path from typing import Any EXPECTED_COMMITS = { "lifecycle": "fe08300339a13f899fb78ea404ada381a5cba87c", "loader": "197623058f509eddde18868dafcb92fdcac66464", "manager": "e23d94ff91233aa770e2342800c1467875bdef44", } EXPECTED_HASHES = { "lifecycle": "bfb4a5cc768e162fe4c2fddf41c3978e152722918a39085277fd172cb95a7182", "loader": "63e810982471eb40cae3a20aa9df9a0a02892f420e429874fae4e99aa400b561", "manager": "8fecf8241a46246eddbd21e8bb4d875f5d76f1f4f4c6a11384df1f131aa5e5b1", } EXPECTED_SIZES = {"lifecycle": 112680, "loader": 397000, "manager": 99560} LIFECYCLE_IMPORTS = {"_exit", "sceKernelSendNotificationRequest"} LIFECYCLE_NEEDED = {"libSceLibcInternal.sprx", "libkernel_web.sprx"} LIFECYCLE_FORBIDDEN = re.compile( r"(sceGnm|VideoOut|SDL_|sceNet|socket|connect|dlopen|dlsym|" r"kernel_copyin|kernel_copyout|kernel_set_ucred|__patch_init)" ) SENSITIVE_CATEGORIES = { "dynamic_loading": re.compile( r"(^|_)(dlopen|dlsym)$|^__(dlopen|dlsym)$|dynlib|" r"LoadStartModule|StopUnloadModule" ), "graphics_or_display": re.compile( r"sceGnm|[Vv]ideoOut|SDL_|[Gg]pu|[Mm][Mm][Ii][Oo]" ), "kernel_runtime_write": re.compile( r"__patch_init|kernel_copy(in|out)|kernel_set_ucred|" r"kernel_overlap_sockets|syscall.*(patch|set)|" r"(patch|set).*syscall" ), "network": re.compile(r"(^|_)(socket|connect|listen|accept)$|sceNet"), "ptrace_or_jit": re.compile(r"ptrace|PT_[A-Z]|[Jj][Ii][Tt]"), } def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def sha256_file(path: Path) -> str: return sha256_bytes(path.read_bytes()) def run(command: list[str]) -> str: result = subprocess.run( command, check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", ) return result.stdout.replace("\r\n", "\n") def git_head(path: Path, expected: str, lifecycle_source: bool = False) -> str: git = [ "git", "-c", "core.autocrlf=true", "-c", "core.fileMode=false", "-C", str(path), ] head = run([*git, "rev-parse", "HEAD"]).strip() if lifecycle_source: subprocess.run( [*git, "merge-base", "--is-ancestor", expected, head], check=True, ) changed = subprocess.run( [ *git, "diff", "--quiet", expected, "--", "samples/lifecycle_probe/main.c", ], check=False, ) if changed.returncode != 0: raise ValueError(f"{path}: lifecycle source differs from {expected}") return expected if head != expected: raise ValueError(f"{path}: expected {expected}, got {head}") if run([*git, "status", "--porcelain"]): raise ValueError(f"{path}: source tree is dirty") return head def parse_undefined(symbols: str) -> list[str]: imports: set[str] = set() for line in symbols.splitlines(): match = re.search(r"\bU\s+(\S+)\s*$", line) if match: imports.add(match.group(1)) return sorted(imports) def parse_needed(dynamic: str) -> list[str]: return sorted(set(re.findall(r"Shared library: \[([^\]]+)\]", dynamic))) def parse_callgraph(disassembly: str) -> list[dict[str, Any]]: current = "" edges: list[dict[str, Any]] = [] for line in disassembly.splitlines(): function = re.match(r"^[0-9a-fA-F]+\s+<([^>]+)>:$", line) if function: current = function.group(1) continue call = re.match( r"^\s*([0-9a-fA-F]+):.*\bcallq?\b\s+(.+?)\s*$", line ) if not call: continue operand = call.group(2).strip() target_match = re.search(r"<([^>]+)>", operand) edges.append( { "address": f"0x{call.group(1).lower()}", "from": current, "indirect": "*" in operand or target_match is None, "target": target_match.group(1) if target_match else operand, } ) return edges def function_name(name: str) -> str: return re.sub(r"\+0x[0-9a-fA-F]+$", "", name) def defined_functions(disassembly: str) -> set[str]: return { match.group(1) for match in re.finditer( r"^[0-9a-fA-F]+\s+<([^>]+)>:$", disassembly, re.MULTILINE ) } def map_symbols(linker_map: str) -> set[str]: symbols = set() pattern = re.compile( r"^\s*[0-9a-fA-F]+\s+[0-9a-fA-F]+\s+[0-9a-fA-F]+" r"\s+\d+\s+([A-Za-z_][A-Za-z0-9_.$@]*)\s*$" ) for line in linker_map.splitlines(): match = pattern.match(line) if match: symbols.add(match.group(1)) return symbols def direct_reachable( callgraph: list[dict[str, Any]], entrypoint: str ) -> set[str]: adjacency: dict[str, set[str]] = {} for edge in callgraph: if edge["indirect"]: continue source = function_name(edge["from"]) target = function_name(edge["target"]) adjacency.setdefault(source, set()).add(target) reachable = {entrypoint} pending = [entrypoint] while pending: source = pending.pop() for target in adjacency.get(source, set()): if target not in reachable: reachable.add(target) pending.append(target) return reachable def sensitive_inventory( disassembly: str, linker_map: str, callgraph: list[dict[str, Any]], entrypoint: str, ) -> dict[str, Any]: defined = defined_functions(disassembly) mapped = map_symbols(linker_map) reachable = direct_reachable(callgraph, entrypoint) categories = {} for category, pattern in SENSITIVE_CATEGORIES.items(): disassembly_linked = sorted( name for name in defined if pattern.search(name) ) map_linked = sorted(name for name in mapped if pattern.search(name)) categories[category] = { "directly_reachable_from_entrypoint": sorted( name for name in disassembly_linked if name in reachable ), "linked": sorted(set(disassembly_linked) | set(map_linked)), "linked_in_disassembly": disassembly_linked, "linked_in_linker_map": map_linked, } return { "categories": categories, "direct_call_reachability_available": entrypoint in defined, "direct_call_reachability_only": True, "entrypoint": entrypoint, "indirect_call_edges_retained_but_not_resolved": sum( 1 for edge in callgraph if edge["indirect"] ), } def normalize_map(data: str) -> str: return re.sub( r"/tmp/([A-Za-z0-9_]+)-[0-9a-f]{6}\.o", r"/tmp/\1-.o", data.replace("\r\n", "\n"), ) def analyze( *, name: str, first: Path, second: Path, map_path: Path, nm: Path, readelf: Path, objdump: Path, output: Path, ) -> dict[str, Any]: first_bytes = first.read_bytes() second_bytes = second.read_bytes() if first_bytes != second_bytes: raise ValueError(f"{name}: the two clean builds are not byte-identical") digest = sha256_bytes(first_bytes) if digest != EXPECTED_HASHES[name] or len(first_bytes) != EXPECTED_SIZES[name]: raise ValueError(f"{name}: identity differs from the reviewed profile") dynamic = run([str(readelf), "-d", str(first)]) symbols = run([str(nm), "-u", str(first)]) headers = run([str(readelf), "-h", "-l", "-S", str(first)]) relocations = run([str(readelf), "-r", str(first)]) all_symbols = run([str(readelf), "-Ws", str(first)]) disassembly = run([str(objdump), "-d", str(first)]) callgraph = parse_callgraph(disassembly) normalized_map = normalize_map(map_path.read_text(encoding="utf-8")) sensitive = sensitive_inventory( disassembly, normalized_map, callgraph, "_start" ) reports = { "callgraph": json.dumps(callgraph, indent=2, sort_keys=True) + "\n", "disassembly": disassembly, "dynamic": dynamic, "headers_sections": headers, "linker_map_normalized": normalized_map, "relocations": relocations, "symbols": all_symbols, "undefined": symbols, } report_hashes: dict[str, str] = {} for report_name, content in reports.items(): report_path = output / f"{name}.{report_name}.txt" report_path.write_text(content, encoding="utf-8", newline="\n") report_hashes[report_name] = sha256_file(report_path) imports = parse_undefined(symbols) needed = parse_needed(dynamic) if name == "lifecycle": if set(imports) != LIFECYCLE_IMPORTS: raise ValueError(f"lifecycle: unexpected imports {imports}") if set(needed) != LIFECYCLE_NEEDED: raise ValueError(f"lifecycle: unexpected DT_NEEDED {needed}") forbidden_matches = sorted(set(LIFECYCLE_FORBIDDEN.findall(symbols))) if forbidden_matches: raise ValueError(f"lifecycle: forbidden import {forbidden_matches}") if b"phase07-fw960-v1" not in first_bytes or b"9.60" not in first_bytes: raise ValueError("lifecycle: firmware/build gates are absent") return { "byte_identical_clean_builds": True, "call_edges": callgraph, "dt_needed": needed, "filename": first.name, "imports": imports, "report_sha256": report_hashes, "sensitive_static_inventory": sensitive, "sha256": digest, "size": len(first_bytes), } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, required=True) parser.add_argument("--loader-source", type=Path, required=True) parser.add_argument("--manager-source", type=Path, required=True) parser.add_argument("--artifact-dir", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--manifest-output", type=Path, required=True) parser.add_argument("--nm", type=Path, required=True) parser.add_argument("--readelf", type=Path, required=True) parser.add_argument("--objdump", type=Path, required=True) args = parser.parse_args() root = args.root.resolve() sources = { "lifecycle": root, "loader": args.loader_source.resolve(), "manager": args.manager_source.resolve(), } source_commits = { name: git_head( path, EXPECTED_COMMITS[name], lifecycle_source=name == "lifecycle" ) for name, path in sources.items() } artifact_dir = args.artifact_dir.resolve() output = args.output.resolve() output.mkdir(parents=True, exist_ok=True) artifacts: dict[str, Any] = {} for name, stem in { "lifecycle": "chimera-gfx-lifecycle-probe-phase07", "loader": "chimera-elfldr-phase07", "manager": "chimera-pldmgr-phase07", }.items(): artifacts[name] = analyze( name=name, first=artifact_dir / f"{stem}-a.elf", second=artifact_dir / f"{stem}-b.elf", map_path=artifact_dir / f"{stem}-b.map", nm=args.nm.resolve(strict=True), readelf=args.readelf.resolve(strict=True), objdump=args.objdump.resolve(strict=True), output=output, ) document = { "schema_version": 1, "decision": "READY_FOR_HARDENED_RUNTIME_DEPLOYMENT", "firmware": "9.60", "offline_only": True, "ps5_actions": { "connected": False, "installed": False, "transferred": False, "executed": False, }, "source_commits": source_commits, "artifacts": artifacts, "forbidden_lifecycle_imports": [], "permanent_denylist_sha256": ( "4be1c17b4964f2b68c39b5145bc4af4619c32512d60269ecf5c39728b390fa63" ), } machine_output = output / "phase-0.7-offline-audit.json" content = json.dumps(document, indent=2, sort_keys=True) + "\n" machine_output.write_text(content, encoding="utf-8", newline="\n") args.manifest_output.resolve().write_text( content, encoding="utf-8", newline="\n" ) print( "Phase-0.7 audit passed: three byte-identical ELF pairs, exact " "firmware 9.60 lifecycle imports, and no PS5 action" ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, subprocess.CalledProcessError, ValueError) as error: print(f"Phase-0.7 artifact audit failed: {error}") raise SystemExit(1) from error