diff --git a/pyproject.toml b/pyproject.toml index 47eee54..009d903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xmlcli" -version = "2.0.5" +version = "2.0.6" description = "UFFAF - UEFI Firmware Foundational Automation Framework (formerly Xml-Cli)" authors = ["Gahan Saraya "] maintainers = ["Intel "] @@ -55,3 +55,4 @@ indent-size = 2 [tool.poetry.scripts] xmlcli = { reference = "xmlcli.start_xmlcli:cli", type = "console" } +uefi-analyze = { reference = "xmlcli.modules.uefi_analyzer.cli:main", type = "console" } diff --git a/src/xmlcli/_version.py b/src/xmlcli/_version.py index 9ae89c2..7e7db65 100644 --- a/src/xmlcli/_version.py +++ b/src/xmlcli/_version.py @@ -15,7 +15,7 @@ def __str__(self): # MINOR ------------ MINOR = 0 # BUILD ------ -BUILD = 5 # or __revision__ +BUILD = 6 # or __revision__ # TAG ------- TAG = "" diff --git a/src/xmlcli/common/bios_fw_parser.py b/src/xmlcli/common/bios_fw_parser.py index ef6211e..a76e90b 100644 --- a/src/xmlcli/common/bios_fw_parser.py +++ b/src/xmlcli/common/bios_fw_parser.py @@ -710,9 +710,10 @@ def write_result_to_file(self, file_path, **kwargs): } try: import xmlcli - output_dict["module_version"] = xmlcli._version.__version__.vstring + ver = xmlcli._version.__version__ + output_dict["module_version"] = ver.vstring if hasattr(ver, 'vstring') else str(ver) del xmlcli - except ImportError or AttributeError: + except (ImportError, AttributeError): pass with open(file_path, "w") as f: diff --git a/src/xmlcli/modules/uefi_analyzer/README.md b/src/xmlcli/modules/uefi_analyzer/README.md new file mode 100644 index 0000000..22dd2bf --- /dev/null +++ b/src/xmlcli/modules/uefi_analyzer/README.md @@ -0,0 +1,48 @@ +# UEFI Firmware Analyzer + +The UEFI Firmware Analyzer is a comprehensive tool within the `xml-cli` framework designed to parse, analyze, and visualize the structure and space utilization of UEFI firmware binaries. + +## Features + +- **Physical Flash Analysis**: Calculates space occupancy based on the actual flash layout (32MB/16MB/etc). +- **Deep Analysis Mode**: Visualizes decompressed components, providing a "logical" view of the firmware (often exceeding the physical size due to decompression). +- **Interactive Dashboard**: A self-contained HTML report with dynamic charts, progress bars for every level of hierarchy, and real-time search. +- **Smart Search**: Search by Driver Name, GUID, or `FileNameString`. Matching items are automatically expanded for easy discovery. +- **Address Mapping**: Displays absolute hexadecimal start and end address ranges for every component in the hierarchy. + +## Quick Start + +### 1. Command Line (Unified Flow) +You can analyze a **binary firmware** or an **existing JSON report** in one command: +```powershell +# Run the analysis (if installed in environment) +uefi-analyze "C:\path\to\bios.bin" +``` +*Note: This generates the JSON, calculates metrics, and automatically opens your browser.* + +### 2. Windows Context Menu +Analyze any `.bin`, `.rom`, `.fd`, or `.json` file directly from Windows Explorer: +1. **Install Menu**: Run `python src/xmlcli/modules/winContextMenu/install_context_menu.py`. +2. **Right-Click**: Select `XmlCli Menu` > `Analyze UEFI Firmware and View`. +3. **Result**: The tool detects the file type and opens the interactive dashboard immediately. + +## Advanced Workflow + +If you need to perform steps manually: + +### Step 1: Parse the Binary to JSON +```python +from xmlcli.common.bios_fw_parser import UefiParser +parser = UefiParser(bin_file="path/to/bios.bin") +output_dict = parser.parse_binary() +parser.write_result_to_file("output.json", output_dict=output_dict) +``` + +### Step 2: Generate the Analysis Dashboard +```powershell +uefi-analyze "C:\path\to\output.json" +``` + +## Output Locations +Results (JSON and HTML) are saved to: +- `C:\Users\\AppData\Local\Temp\XmlCliOut\logs\result\analytic_view\` diff --git a/src/xmlcli/modules/uefi_analyzer/__init__.py b/src/xmlcli/modules/uefi_analyzer/__init__.py new file mode 100644 index 0000000..430f31c --- /dev/null +++ b/src/xmlcli/modules/uefi_analyzer/__init__.py @@ -0,0 +1,4 @@ +# coding=utf-8 +""" +UEFI Analyzer Module +""" diff --git a/src/xmlcli/modules/uefi_analyzer/analyze_view.py b/src/xmlcli/modules/uefi_analyzer/analyze_view.py new file mode 100644 index 0000000..445517e --- /dev/null +++ b/src/xmlcli/modules/uefi_analyzer/analyze_view.py @@ -0,0 +1,50 @@ +# coding=utf-8 +""" +Entry point for BIOS Analysis and View Generation +""" +import os +import sys +import argparse +import tempfile +from xmlcli.modules.uefi_analyzer import bios_analyzer +from xmlcli.modules.uefi_analyzer import report_generator +from xmlcli.common import configurations + +def main(): + parser = argparse.ArgumentParser(description="BIOS Analysis View Generator") + parser.add_argument("json_files", nargs="+", help="JSON files produced by UefiParser") + parser.add_argument("--output-dir", help="Directory to store analysis results") + args = parser.parse_args() + + # Determine output directory + # User requested temp workspace in LOG_FILE_LOCATION as directory result analytic_view + # Based on configurations.py and logger.py, we can construct this. + + log_dir = os.path.join(configurations.OUT_DIR, "logs") + default_output_dir = os.path.join(log_dir, "result", "analytic_view") + + # Also consider the temp workspace as requested + temp_workspace = os.path.join(tempfile.gettempdir(), "XmlCliOut", "logs", "result", "analytic_view") + + output_dir = args.output_dir or temp_workspace + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + print(f"Analyzing {len(args.json_files)} files...") + analyzer = bios_analyzer.BiosAnalyzer(args.json_files) + analysis_results = analyzer.analyze_all() + + # Save the raw analysis JSONs + analyzer.save_analysis(output_dir) + + # Generate the HTML dashboard + report_file = os.path.join(output_dir, "dashboard.html") + report_generator.generate_report(analysis_results, report_file) + + print(f"Analysis complete.") + print(f"Results saved to: {output_dir}") + print(f"Dashboard available at: {report_file}") + +if __name__ == "__main__": + main() diff --git a/src/xmlcli/modules/uefi_analyzer/bios_analyzer.py b/src/xmlcli/modules/uefi_analyzer/bios_analyzer.py new file mode 100644 index 0000000..dddbeee --- /dev/null +++ b/src/xmlcli/modules/uefi_analyzer/bios_analyzer.py @@ -0,0 +1,287 @@ +# coding=utf-8 +""" +BIOS Analyzer utility to calculate: +- Free Space (FV, FFS, Sections) +- Compression Ratio +- Comparing multiple JSON files +""" + +import os +import json +from collections import OrderedDict + +class BiosAnalyzer: + def __init__(self, json_files=None): + self.json_files = json_files or [] + self.analyzed_data = {} + + def load_json(self, file_path): + with open(file_path, 'r') as f: + return json.load(f) + + def analyze_all(self): + for file_path in self.json_files: + data = self.load_json(file_path) + name = data.get("name", os.path.basename(file_path)) + # Analyze and also keep raw JSON for UI tree view + analysis = self.analyze_data(data) + analysis["raw"] = data # store original JSON payload + self.analyzed_data[name] = analysis + return self.analyzed_data + + def analyze_data(self, data): + """Analyze a full JSON payload. + Separates Flash (Physical) and Logical (Decompressed) metrics. + """ + inner_data = data.get("data", {}) + total_binary_size = data.get("size", 0) # Top level size (e.g. 32MB) + + # 1. Identify all nested FVs to exclude from physical summary + nested_fv_keys = set() + def find_nested_fvs(d): + if isinstance(d, dict): + for k, v in d.items(): + if k == "FV" and isinstance(v, dict): + nested_fv_keys.update(v.keys()) + find_nested_fvs(v) + find_nested_fvs(inner_data) + + # 2. Identify root-level FVs + root_fv_entries = [] + for key, value in inner_data.items(): + if "-FVI-" in key and key not in nested_fv_keys: + root_fv_entries.append((key, value)) + + root_fv_entries.sort(key=lambda x: int(x[0].split('-')[0], 16)) + + # 3. Calculate Physical Allocation + # Sum of sizes of root FVs is "Total Used Space" in flash context + root_fv_total_allocated = sum(int(k.split('-')[2], 16) for k, _ in root_fv_entries) + physical_free = total_binary_size - root_fv_total_allocated if total_binary_size else 0 + + # 4. Deep Analysis (Collect all FVs and Drivers) + all_fv_analyses = {} + def collect_fvs(d): + if isinstance(d, dict): + for k, v in d.items(): + if "-FVI-" in k and k not in all_fv_analyses: + all_fv_analyses[k] = self.analyze_fv(k, v) + collect_fvs(v) + collect_fvs(inner_data) + + # 5. Build Summaries + logical_used = 0 + logical_free = 0 + logical_driver_counts = {} + logical_space_by_type = {} + + # Breakdown of what's inside root FVs (for charts) + physical_driver_counts = {} + physical_space_by_type = {} + + # Physical: Based on root FVs + for k, _ in root_fv_entries: + if k in all_fv_analyses: + info = all_fv_analyses[k] + for t, s in info["space_by_type"].items(): + physical_space_by_type[t] = physical_space_by_type.get(t, 0) + s + for t, c in info["driver_counts"].items(): + physical_driver_counts[t] = physical_driver_counts.get(t, 0) + c + + # Logical: Based on ALL FVs (including decompressed) + for k, info in all_fv_analyses.items(): + logical_used += info["used_space"] + logical_free += info["free_space"] + for t, s in info["space_by_type"].items(): + logical_space_by_type[t] = logical_space_by_type.get(t, 0) + s + for t, c in info["driver_counts"].items(): + logical_driver_counts[t] = logical_driver_counts.get(t, 0) + c + + analysis = { + "total_size": total_binary_size, + "fvs": [all_fv_analyses[k] for k in sorted(all_fv_analyses.keys())], + "root_fv_keys": [k for k, _ in root_fv_entries], + "summary": { + "physical": { + "total_size": total_binary_size, + "used_space": root_fv_total_allocated, + "free_space": max(0, physical_free), + "driver_counts": physical_driver_counts, + "space_by_type": physical_space_by_type + }, + "logical": { + "used_space": logical_used, + "free_space": logical_free, + "driver_counts": logical_driver_counts, + "space_by_type": logical_space_by_type + } + } + } + + # Compatibility layers + analysis["summary"]["total_used_space"] = root_fv_total_allocated + analysis["summary"]["total_free_space"] = max(0, physical_free) + + return analysis + + def analyze_fv(self, fv_key, fv_data): + # Key format: 0x-FVI-0x + parts = fv_key.split('-') + addr = int(parts[0], 16) + size = int(parts[2], 16) + + fv_info = { + "address": addr, + "size": size, + "name": fv_data.get("FvNameGuid", "Unknown"), + "ffs": [], + "free_space": 0, + "used_space": 0, + "ffs_count": 0, + "pad_file_space": 0, + "space_by_type": {}, + "driver_counts": {}, + "compression_info": [] + } + + # Look for FFS entries + for sub_key, sub_val in fv_data.items(): + if any(x in sub_key for x in ["FFS1", "FFS2", "FFS3"]): + for ffs_key, ffs_val in sub_val.items(): + ffs_info = self.analyze_ffs(ffs_key, ffs_val) + fv_info["ffs"].append(ffs_info) + + ffs_type = ffs_info["type"] + fv_info["space_by_type"][ffs_type] = fv_info["space_by_type"].get(ffs_type, 0) + ffs_info["size"] + fv_info["driver_counts"][ffs_type] = fv_info["driver_counts"].get(ffs_type, 0) + 1 + + if ffs_type == "FV_FILETYPE_FFS_PAD": + fv_info["pad_file_space"] += ffs_info["size"] + else: + fv_info["used_space"] += ffs_info["size"] + + # Track compression + for sec in ffs_info["sections"]: + if sec["is_compressed"]: + fv_info["compression_info"].append({ + "ffs_guid": ffs_info["guid"], + "compressed_size": sec["size"], + "uncompressed_size": sec["uncompressed_size"], + "ratio": sec["compression_ratio"] + }) + + fv_info["ffs_count"] = len(fv_info["ffs"]) + # Free space = Total size - Used space (excluding Pad files which are technically free) + fv_info["free_space"] = size - fv_info["used_space"] + if fv_info["free_space"] < 0: fv_info["free_space"] = 0 + + return fv_info + + def analyze_ffs(self, ffs_key, ffs_data): + parts = ffs_key.split('-') + size = int(parts[2], 16) + + ffs_info = { + "size": size, + "type": ffs_data.get("Type", "Unknown"), + "guid": ffs_data.get("Name", "Unknown"), + "sections": [], + "compressed_sections": [] + } + + if "section" in ffs_data: + for sec_key, sec_val in ffs_data["section"].items(): + sec_info = self.analyze_section(sec_key, sec_val) + ffs_info["sections"].append(sec_info) + if sec_info["is_compressed"]: + ffs_info["compressed_sections"].append(sec_info) + + return ffs_info + + def analyze_section(self, sec_key, sec_data): + parts = sec_key.split('-') + size = int(parts[2], 16) + + sec_info = { + "size": size, + "type": sec_data.get("SectionType", "Unknown"), + "is_compressed": False, + "uncompressed_size": size, + "compression_ratio": 1.0, + "sub_fvs": [] + } + + if sec_info["type"] == "EFI_SECTION_COMPRESSION": + sec_info["is_compressed"] = True + sec_info["uncompressed_size"] = sec_data.get("UncompressedLength", size) + if size > 0: + sec_info["compression_ratio"] = sec_info["uncompressed_size"] / size + + if "encapsulation" in sec_data: + # Recursively analyze encapsulated sections + for enc_key, enc_val in sec_data["encapsulation"].items(): + enc_info = self.analyze_section(enc_key, enc_val) + # If any encapsulated section is compressed, bubble it up or handle it + if enc_info["is_compressed"]: + sec_info["is_compressed"] = True + sec_info["uncompressed_size"] = enc_info["uncompressed_size"] + sec_info["compression_ratio"] = enc_info["compression_ratio"] + + if "FV" in sec_data: + for fv_key, fv_val in sec_data["FV"].items(): + fv_info = self.analyze_fv(fv_key, fv_val) + sec_info["sub_fvs"].append(fv_info) + + return sec_info + + def compare(self, name1, name2): + if name1 not in self.analyzed_data or name2 not in self.analyzed_data: + return {"error": "Files not analyzed"} + + data1 = self.analyzed_data[name1] + data2 = self.analyzed_data[name2] + + comparison = { + "name1": name1, + "name2": name2, + "fv_diff": [] + } + + fvs1 = {fv["name"]: fv for fv in data1["fvs"]} + fvs2 = {fv["name"]: fv for fv in data2["fvs"]} + + all_fv_names = set(fvs1.keys()) | set(fvs2.keys()) + + for fv_name in all_fv_names: + fv1 = fvs1.get(fv_name) + fv2 = fvs2.get(fv_name) + + diff = { + "name": fv_name, + "size_diff": (fv2["size"] - fv1["size"]) if fv1 and fv2 else None, + "free_space_diff": (fv2["free_space"] - fv1["free_space"]) if fv1 and fv2 else None, + "used_space_diff": (fv2["used_space"] - fv1["used_space"]) if fv1 and fv2 else None, + "status": "changed" if fv1 and fv2 else ("added" if fv2 else "removed") + } + comparison["fv_diff"].append(diff) + + return comparison + + def save_analysis(self, output_dir): + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + for name, data in self.analyzed_data.items(): + output_file = os.path.join(output_dir, f"{name}_analysis.json") + with open(output_file, 'w') as f: + json.dump(data, f, indent=4) + + # Save comparison if multiple files + if len(self.analyzed_data) >= 2: + names = list(self.analyzed_data.keys()) + for i in range(len(names)): + for j in range(i+1, len(names)): + comp = self.compare(names[i], names[j]) + comp_file = os.path.join(output_dir, f"compare_{names[i]}_vs_{names[j]}.json") + with open(comp_file, 'w') as f: + json.dump(comp, f, indent=4) diff --git a/src/xmlcli/modules/uefi_analyzer/cli.py b/src/xmlcli/modules/uefi_analyzer/cli.py new file mode 100644 index 0000000..68e5bfb --- /dev/null +++ b/src/xmlcli/modules/uefi_analyzer/cli.py @@ -0,0 +1,84 @@ +# coding=utf-8 +""" +Unified CLI for UEFI Firmware Analysis +Takes a binary as input, parses it, analyzes it, and opens the dashboard. +""" +import os +import sys +import webbrowser +import tempfile +import argparse +from xmlcli.common import bios_fw_parser +from xmlcli.modules.uefi_analyzer import bios_analyzer +from xmlcli.modules.uefi_analyzer import report_generator + +def analyze_binary(bin_file, output_dir=None, open_browser=True): + bin_file = os.path.abspath(bin_file) + if not os.path.exists(bin_file): + print(f"Error: File not found: {bin_file}") + return None + + is_json = bin_file.lower().endswith('.json') + + if output_dir is None: + if is_json: + output_dir = os.path.dirname(bin_file) + else: + output_dir = os.path.join(tempfile.gettempdir(), "XmlCliOut", "logs", "result", "analytic_view") + + output_dir = os.path.abspath(output_dir) + if not os.path.exists(output_dir): + print(f"Creating output directory: {output_dir}") + os.makedirs(output_dir, exist_ok=True) + + json_path = bin_file + if not is_json: + print(f"--- Step 1: Parsing Binary {os.path.basename(bin_file)} ---") + # Ensure we don't accidentally double-parse if given a JSON + uefi_parser = bios_fw_parser.UefiParser(bin_file=bin_file, clean=False) + output_dict = uefi_parser.parse_binary() + output_dict = uefi_parser.sort_output_fv(output_dict) + + # Use filename without doubling .json + base_name = os.path.splitext(os.path.basename(bin_file))[0] + json_path = os.path.abspath(os.path.join(output_dir, f"{base_name}.json")) + + # Ensure directory exists again just in case + os.makedirs(output_dir, exist_ok=True) + + print(f"Writing JSON result to: {json_path}") + uefi_parser.write_result_to_file(json_path, output_dict=output_dict) + print(f"JSON saved successfully.") + else: + print(f"--- Step 1: Using provided JSON {os.path.basename(bin_file)} ---") + + print(f"--- Step 2: Analyzing Structure ---") + analyzer = bios_analyzer.BiosAnalyzer([json_path]) + analysis_results = analyzer.analyze_all() + # Save the analysis summary next to our JSON + analyzer.save_analysis(output_dir) + + print(f"--- Step 3: Generating Dashboard ---") + report_file = os.path.abspath(os.path.join(output_dir, "dashboard.html")) + report_generator.generate_report(analysis_results, report_file) + + print(f"\nAnalysis complete!") + print(f"HTML Dashboard: {report_file}") + + if open_browser: + print("Opening dashboard in browser...") + webbrowser.open(f"file:///{report_file}") + + return report_file + +def main(): + parser = argparse.ArgumentParser(description="UEFI Firmware Analysis Tool") + parser.add_argument("binary", help="Path to UEFI firmware binary (.bin, .rom, .fd)") + parser.add_argument("--output-dir", help="Optional output directory") + parser.add_argument("--no-browser", action="store_true", help="Do not open browser automatically") + + args = parser.parse_args() + analyze_binary(args.binary, output_dir=args.output_dir, open_browser=not args.no_browser) + +if __name__ == "__main__": + main() diff --git a/src/xmlcli/modules/uefi_analyzer/report_generator.py b/src/xmlcli/modules/uefi_analyzer/report_generator.py new file mode 100644 index 0000000..f6f3f45 --- /dev/null +++ b/src/xmlcli/modules/uefi_analyzer/report_generator.py @@ -0,0 +1,554 @@ +# coding=utf-8 +""" +HTML Report Generator for UEFI Firmware Analysis +""" +import os +import json + +HTML_TEMPLATE = """ + + + + + + UEFI Firmware Analysis Dashboard + + + + +
+
+

UEFI Firmware Analysis Dashboard

+
+
+ +
+ +
+
+

Space Distribution by Type

+
+
+
+

Free/Used Ratio

+
+
+
+ +
+ + +
+ +
+
+ + + + +""" + +def generate_report(analysis_data, output_file): + report_content = HTML_TEMPLATE.replace("{{DATA_JSON}}", json.dumps(analysis_data, ensure_ascii=False)) + with open(output_file, 'w', encoding='utf-8') as f: + f.write(report_content) + return output_file diff --git a/src/xmlcli/modules/winContextMenu/xmlcli_registry_listener.py b/src/xmlcli/modules/winContextMenu/xmlcli_registry_listener.py index e18088e..8fd630c 100644 --- a/src/xmlcli/modules/winContextMenu/xmlcli_registry_listener.py +++ b/src/xmlcli/modules/winContextMenu/xmlcli_registry_listener.py @@ -36,10 +36,11 @@ def __init__(self, xmlcli_path=None): pass # raise utils.XmlCliException("Unsafe file path used...") self.command_method_map = OrderedDict({ - "all" : CMD_MAP("all", self.command_all, "Run All"), - "savexml" : CMD_MAP("savexml", self.command_savexml, "Save XML"), - "generatejson": CMD_MAP("generatejson", self.command_generate_json, "Parse firmware as json"), - "shell" : CMD_MAP("shell", self.command_launch_shell, "Launch Shell"), + "all" : CMD_MAP("all", self.command_all, "Run All"), + "savexml" : CMD_MAP("savexml", self.command_savexml, "Save XML"), + "generatejson" : CMD_MAP("generatejson", self.command_generate_json, "Parse firmware as json"), + "analyze_uefi" : CMD_MAP("analyze_uefi", self.command_analyze_uefi, "Analyze UEFI Firmware and View"), + "shell" : CMD_MAP("shell", self.command_launch_shell, "Launch Shell"), }) def create_registry_file(self, context_menu_name="XmlCli Menu", icon=""): @@ -98,6 +99,12 @@ def command_generate_json(self): output = uefi_parser.sort_output_fv(output) output_json_file = os.path.join(self.output_directory, "{}.json".format(self.binary_file_name)) uefi_parser.write_result_to_file(output_json_file, output_dict=output) + return output_json_file + + def command_analyze_uefi(self): + output_json_file = self.command_generate_json() + from xmlcli.modules.uefi_analyzer import cli + cli.analyze_binary(output_json_file) def command_savexml(self): output_xml_file = os.path.join(self.output_directory, "{}.xml".format(self.binary_file_name))