diff --git a/README.md b/README.md index 91bd126e04..38e1c6c992 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ For more details, see our [documentation](https://cve-bin-tool.readthedocs.io/en - [Scanning an SBOM file for known vulnerabilities](#scanning-an-sbom-file-for-known-vulnerabilities) - [Generating an SBOM](#generating-an-sbom) - [Generating a VEX](#generating-a-vex) + - [Archiving VEX Files](#archiving-vex-files) - [Triaging vulnerabilities](#triaging-vulnerabilities) - [Using the tool offline](#using-the-tool-offline) - [Using CVE Binary Tool in GitHub Actions](#using-cve-binary-tool-in-github-actions) @@ -134,6 +135,28 @@ Valid VEX types are [CSAF](https://oasis-open.github.io/csaf-documentation/), [C The [VEX generation how-to guide](https://github.com/intel/cve-bin-tool/blob/main/doc/how_to_guides/vex_generation.md) provides additional VEX generation examples. +### Archiving VEX Files + +When software components are updated or removed, VEX files may contain obsolete vulnerability triage information. The VEX archive feature helps maintain clean and current VEX files: + +```bash +cve-bin-tool vex-archive --vex --report +``` + +This command: +- Identifies VEX entries for components no longer present in your scan +- Archives obsolete vulnerability triage information to a separate file +- Creates backup files for safety +- Generates detailed operation summaries + +Use `--dry-run` to preview changes without modifying files: + +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report current-scan.json --dry-run +``` + +The [VEX archiving how-to guide](https://github.com/intel/cve-bin-tool/blob/main/doc/how_to_guides/vex_archiving.md) provides detailed usage instructions and examples. + ### Triaging vulnerabilities The `--vex-file` option can be used to add extra triage data like remarks, comments etc. while scanning a directory so that output will reflect this triage data and you can save time of re-triaging (Usage: `cve-bin-tool --vex-file test.json /path/to/scan`). diff --git a/cve_bin_tool/cli.py b/cve_bin_tool/cli.py index 451783ec01..8d2ce53007 100644 --- a/cve_bin_tool/cli.py +++ b/cve_bin_tool/cli.py @@ -94,6 +94,83 @@ def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, values) +def vex_archive_main(argv): + """Handle the vex-archive subcommand""" + from cve_bin_tool.vex_manager.archive import VEXArchiveManager + + parser = argparse.ArgumentParser( + prog="cve-bin-tool vex-archive", + description="Archive obsolete VEX entries based on new scan reports. " + "Remove or archive triage information that is no longer applicable " + "(for example, when a component is updated and the original CVE no longer applies)", + ) + + parser.add_argument( + "--vex", + required=True, + help="Path to the VEX file you want to clean up", + metavar="VEX_FILE_PATH", + ) + + parser.add_argument( + "--report", + required=True, + help="Path to a new cve-bin-tool JSON scan report", + metavar="NEW_REPORT_PATH", + ) + + parser.add_argument( + "--archive-file", + default="vex-archive.json", + help="File where obsolete entries will be stored (default: vex-archive.json)", + metavar="ARCHIVE_FILE_PATH", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be archived without making any changes", + ) + + args = parser.parse_args(argv) + + # Process VEX archiving + try: + archive_manager = VEXArchiveManager( + vex_file_path=args.vex, + report_file_path=args.report, + archive_file_path=args.archive_file, + dry_run=args.dry_run, + ) + + result = archive_manager.process() + + if result["success"]: + if args.dry_run: + if result["obsolete_entries"] > 0: + LOGGER.info( + f"DRY RUN: Would archive {result['obsolete_entries']} obsolete entries" + ) + else: + LOGGER.info( + "DRY RUN: No obsolete entries found - VEX file is up to date" + ) + elif result["obsolete_entries"] > 0: + LOGGER.info( + f"Success: Archived {result['obsolete_entries']} obsolete entries to {result['archive_file']}. VEX file backup and archive summary created." + ) + else: + LOGGER.info("No obsolete entries found - VEX file is up to date") + return 0 + else: + LOGGER.error(f"VEX archiving failed: {result['error']}") + return 1 + + except Exception as e: + LOGGER.error(f"Unexpected error: {e}") + return 1 + + def main(argv=None): """Scan a binary file for certain open source libraries that may have CVEs""" if sys.version_info < (3, 8): @@ -105,6 +182,10 @@ def main(argv=None): # Reset logger level to info LOGGER.setLevel(logging.INFO) + # Check if this is a vex-archive command + if len(argv) > 1 and argv[1] == "vex-archive": + return vex_archive_main(argv[2:]) + parser = argparse.ArgumentParser( prog="cve-bin-tool", description=textwrap.dedent( diff --git a/cve_bin_tool/util.py b/cve_bin_tool/util.py index 11ee0533f4..721d70e840 100644 --- a/cve_bin_tool/util.py +++ b/cve_bin_tool/util.py @@ -448,9 +448,13 @@ def decode_bom_ref(ref: str): else: return None - if product and vendor and version: - if validate_product_vendor(product, vendor) and validate_version(version): - return ProductInfo(vendor.strip(), product.strip(), version.strip()) + if product and version: + # Handle case where vendor might be None for certain BOM references + vendor_name = vendor.strip() if vendor else "unknown" + if ( + vendor is None or validate_product_vendor(product, vendor) + ) and validate_version(version): + return ProductInfo(vendor_name, product.strip(), version.strip(), location) return None diff --git a/cve_bin_tool/vex_manager/archive.py b/cve_bin_tool/vex_manager/archive.py new file mode 100644 index 0000000000..4b1eb39d55 --- /dev/null +++ b/cve_bin_tool/vex_manager/archive.py @@ -0,0 +1,342 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +VEX Archive Manager + +This module provides functionality to archive obsolete VEX entries based on +current scan results. It helps maintain VEX files by removing triage information +for components that are no longer present in the current environment. +""" + +import json +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Tuple + +from cve_bin_tool.log import LOGGER +from cve_bin_tool.util import ProductInfo +from cve_bin_tool.vex_manager.parse import VEXParse + + +class VEXArchiveManager: + """ + Manages archiving of obsolete VEX entries. + + This class compares VEX files against current scan reports and archives + entries for components that are no longer present, helping to keep VEX + files clean and relevant. + """ + + def __init__( + self, + vex_file_path: str, + report_file_path: str, + archive_file_path: str = "vex-archive.json", + dry_run: bool = False, + ): + """ + Initialize the VEX Archive Manager. + + Args: + vex_file_path: Path to the VEX file to process + report_file_path: Path to the cve-bin-tool JSON report + archive_file_path: Path where archived entries will be stored + dry_run: If True, only analyze without making changes + """ + self.vex_file_path = Path(vex_file_path) + self.report_file_path = Path(report_file_path) + self.archive_file_path = Path(archive_file_path) + self.dry_run = dry_run + + self.vex_data = {} + self.current_products = set() + self.active_entries = [] + self.obsolete_entries = [] + + def load_vex_file(self) -> None: + """Load and parse the VEX file.""" + if not self.vex_file_path.exists(): + raise FileNotFoundError(f"VEX file not found: {self.vex_file_path}") + + LOGGER.info(f"Loading VEX file: {self.vex_file_path}") + try: + vex_parser = VEXParse( + filename=str(self.vex_file_path), + vextype="auto", + logger=LOGGER, + ) + self.vex_data = vex_parser.parse_vex() + LOGGER.info(f"Loaded {len(self.vex_data)} products from VEX file") + except Exception as e: + LOGGER.error(f"Failed to parse VEX file {self.vex_file_path}: {e}") + raise + + def load_report_file(self) -> None: + """Load and parse the cve-bin-tool JSON report.""" + if not self.report_file_path.exists(): + raise FileNotFoundError(f"Report file not found: {self.report_file_path}") + + LOGGER.info(f"Loading scan report: {self.report_file_path}") + try: + with open(self.report_file_path, encoding="utf-8") as f: + report_data = json.load(f) + except Exception as e: + LOGGER.error(f"Failed to load report file {self.report_file_path}: {e}") + raise + + # Extract product IDs from the report + self.current_products = set() + if ( + "vulnerabilities" in report_data + and "report" in report_data["vulnerabilities"] + ): + for datasource in report_data["vulnerabilities"]["report"]: + for entry in datasource.get("entries", []): + vendor = entry.get("vendor", "unknown") + product = entry.get("product", "") + version = entry.get("version", "") + if product: + product_id = f"{vendor}:{product}:{version}" + self.current_products.add(product_id) + + LOGGER.info( + f"Found {len(self.current_products)} unique products in scan report" + ) + + def _product_info_to_id(self, product_info: ProductInfo) -> str: + """Convert ProductInfo object to vendor:product:version format.""" + vendor = product_info.vendor if product_info.vendor else "unknown" + product = product_info.product if product_info.product else "" + version = product_info.version if product_info.version else "" + return f"{vendor}:{product}:{version}" + + def analyze_entries(self) -> Tuple[int, int]: + """ + Analyze VEX entries to identify active vs obsolete entries. + + Returns: + Tuple of (active_count, obsolete_count) + """ + LOGGER.info("Analyzing VEX entries against current scan results") + + self.active_entries = [] + self.obsolete_entries = [] + + for product_info, vex_entries in self.vex_data.items(): + product_id = self._product_info_to_id(product_info) + + entry_data = { + "product_info": product_info, + "product_id": product_id, + "vex_entries": vex_entries, + } + + if product_id in self.current_products: + self.active_entries.append(entry_data) + else: + self.obsolete_entries.append(entry_data) + + active_count = len(self.active_entries) + obsolete_count = len(self.obsolete_entries) + + LOGGER.info( + f"Analysis complete: {active_count} active, {obsolete_count} obsolete entries" + ) + return active_count, obsolete_count + + def print_dry_run_results(self) -> None: + """Print what would be archived in dry run mode.""" + if not self.obsolete_entries: + LOGGER.info("DRY RUN: No obsolete entries found") + return + + LOGGER.info( + f"DRY RUN: Would archive {len(self.obsolete_entries)} obsolete entries:" + ) + LOGGER.info("=" * 60) + + for entry in self.obsolete_entries: + product_id = entry["product_id"] + cve_count = len([k for k in entry["vex_entries"].keys() if k != "paths"]) + LOGGER.info(f" • {product_id} ({cve_count} CVE entries)") + + LOGGER.info("=" * 60) + LOGGER.info(f"Archive file would be created: {self.archive_file_path}") + LOGGER.info("Run without --dry-run to perform the actual archiving") + + def archive_obsolete_entries(self) -> None: + """Archive obsolete entries to the archive file.""" + if not self.obsolete_entries: + LOGGER.info("No obsolete entries to archive") + return + + LOGGER.info( + f"Archiving {len(self.obsolete_entries)} obsolete entries to {self.archive_file_path}" + ) + + # Create archive data structure + archive_data = { + "metadata": { + "tool": "cve-bin-tool vex-archive", + "timestamp": datetime.now().isoformat(), + "source_vex_file": str(self.vex_file_path), + "source_report_file": str(self.report_file_path), + "total_archived_entries": len(self.obsolete_entries), + }, + "archived_entries": [], + } + + for entry in self.obsolete_entries: + # Convert Remarks objects to strings if needed + vex_entries_serializable = {} + for cve_id, cve_data in entry["vex_entries"].items(): + if cve_id == "paths": + # Handle paths set - convert to list + vex_entries_serializable[cve_id] = ( + list(cve_data) if isinstance(cve_data, set) else cve_data + ) + else: + # Handle CVE data + cve_data_serializable = {} + for key, value in cve_data.items(): + if hasattr(value, "value"): # Remarks enum + cve_data_serializable[key] = value.value + elif isinstance(value, set): + cve_data_serializable[key] = list(value) + else: + cve_data_serializable[key] = value + vex_entries_serializable[cve_id] = cve_data_serializable + + archived_entry = { + "product_id": entry["product_id"], + "product_info": { + "vendor": entry["product_info"].vendor, + "product": entry["product_info"].product, + "version": entry["product_info"].version, + "location": entry["product_info"].location, + }, + "vex_data": vex_entries_serializable, + } + archive_data["archived_entries"].append(archived_entry) + + # Write archive file + with open(self.archive_file_path, "w", encoding="utf-8") as f: + json.dump(archive_data, f, indent=2) + + LOGGER.info( + f"Successfully archived obsolete entries to {self.archive_file_path}" + ) + + def update_vex_file(self) -> None: + """Update the original VEX file to remove obsolete entries.""" + if not self.active_entries: + LOGGER.info("No active entries remaining - VEX file would be empty") + return + + LOGGER.info( + f"Updating VEX file to remove {len(self.obsolete_entries)} obsolete entries" + ) + + try: + # For now, create a backup and provide clear instructions + # This is the simplest, safest approach for the initial implementation + backup_path = Path(str(self.vex_file_path) + ".backup") + + # Create backup + shutil.copy2(self.vex_file_path, backup_path) + LOGGER.info(f"Created backup of original VEX file: {backup_path}") + + # Write a note file explaining what was archived + note_path = Path(str(self.vex_file_path) + ".archive_note.txt") + with open(note_path, "w", encoding="utf-8") as f: + f.write("VEX Archive Operation Summary\n") + f.write("============================\n\n") + f.write(f"Date: {datetime.now().isoformat()}\n") + f.write(f"Original VEX file: {self.vex_file_path}\n") + f.write(f"Backup created: {backup_path}\n") + f.write(f"Archive file: {self.archive_file_path}\n\n") + f.write(f"Active entries remaining: {len(self.active_entries)}\n") + f.write(f"Obsolete entries archived: {len(self.obsolete_entries)}\n\n") + f.write("Obsolete entries (no longer in current scan):\n") + for entry in self.obsolete_entries: + f.write(f" - {entry['product_id']}\n") + f.write("Active entries (still in current scan):\n") + for entry in self.active_entries: + f.write(f" - {entry['product_id']}\n") + + LOGGER.info(f"Created archive summary: {note_path}") + LOGGER.info("VEX file backup and archive operation completed successfully") + + except Exception as e: + LOGGER.error(f"Failed to update VEX file: {e}") + raise + + def _detect_vex_type(self) -> str: + """Detect the VEX type from the original file.""" + try: + with open(self.vex_file_path, encoding="utf-8") as f: + content = f.read() + + if "cyclonedx" in content.lower() or "bomformat" in content.lower(): + return "cyclonedx" + elif "csaf_version" in content.lower() or "document" in content.lower(): + return "csaf" + else: + return "openvex" + + except Exception: + # Default to CycloneDX if detection fails + LOGGER.warning("Could not detect VEX type, defaulting to CycloneDX") + return "cyclonedx" + + def process(self) -> Dict[str, Any]: + """ + Main processing method that orchestrates the entire archiving workflow. + + Returns: + Dictionary with processing results + """ + try: + # Load input files + self.load_vex_file() + self.load_report_file() + + # Analyze entries + active_count, obsolete_count = self.analyze_entries() + + # Handle dry run mode + if self.dry_run: + self.print_dry_run_results() + return { + "success": True, + "active_entries": active_count, + "obsolete_entries": obsolete_count, + "archive_file": None, + "vex_file_updated": False, + "dry_run": True, + } + + # Archive obsolete entries (only if not dry run) + if obsolete_count > 0: + self.archive_obsolete_entries() + + # Update VEX file to remove obsolete entries + self.update_vex_file() + else: + LOGGER.info("No obsolete entries found - VEX file is up to date") + + return { + "success": True, + "active_entries": active_count, + "obsolete_entries": obsolete_count, + "archive_file": ( + str(self.archive_file_path) if obsolete_count > 0 else None + ), + "vex_file_updated": obsolete_count > 0, + } + + except Exception as e: + LOGGER.error(f"Error processing VEX archive: {e}") + return {"success": False, "error": str(e)} diff --git a/doc/MANUAL.md b/doc/MANUAL.md index cd5c8c6010..b1aeba9a98 100644 --- a/doc/MANUAL.md +++ b/doc/MANUAL.md @@ -1528,6 +1528,68 @@ You can find the current SBOM for CVE-BIN-TOOL which is updated weekly [here](ht A VEX (Vulnerablity Exploitability eXchange) is document that lists all the vulnerablities found for all the components of a software product, VEX is a companion document to a Software Bill of Materials (SBOM) that helps communicate the exploitability of components with known vulnerabilities in a product and also used as part of filtering/triaging process. +### VEX Archive Command + +The VEX archive feature helps maintain clean and current VEX files by archiving obsolete vulnerability triage information when software components are updated or removed. This command is available as a subcommand of cve-bin-tool. + +#### Basic Usage + +Archive obsolete VEX entries by comparing against a current scan report: + +```bash +cve-bin-tool vex-archive --vex --report +``` + +#### Command Arguments + +**Required Arguments:** +- `--vex VEX_FILE`: Path to the VEX file to process +- `--report REPORT_FILE`: Path to the JSON scan report for comparison + +**Optional Arguments:** +- `--archive-file ARCHIVE_FILE`: Custom path for archived entries (default: `vex-archive.json`) +- `--dry-run`: Preview changes without modifying files + +#### Examples + +**Basic archiving:** +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report current-scan.json +``` + +**Dry run to preview changes:** +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report current-scan.json --dry-run +``` + +**Custom archive file location:** +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report current-scan.json --archive-file archives/old-entries.json +``` + +#### What It Does + +1. **Analyzes** your VEX file against the current scan report +2. **Identifies** VEX entries for components no longer present in the scan +3. **Archives** obsolete vulnerability triage information to a separate file +4. **Creates** backup files before making any changes +5. **Generates** detailed operation summaries and logs + +#### Output Files + +- **Archive file**: Contains archived obsolete entries with full metadata +- **Backup file**: Complete backup of original VEX file (`.backup` extension) +- **Summary file**: Human-readable operation summary (`.archive_note.txt` extension) + +#### Use Cases + +- **Component updates**: Archive outdated vulnerability assessments after software updates +- **Dependency removal**: Clean up VEX files when removing software dependencies +- **Compliance auditing**: Maintain historical security decisions while keeping current files relevant +- **Team workflows**: Keep VEX files synchronized with actual software composition + +For detailed usage instructions and examples, see the [VEX Archiving Guide](how_to_guides/vex_archiving.md). + ## Language Specific checkers diff --git a/doc/how_to_guides/vex_archiving.md b/doc/how_to_guides/vex_archiving.md new file mode 100644 index 0000000000..26bce67a0b --- /dev/null +++ b/doc/how_to_guides/vex_archiving.md @@ -0,0 +1,270 @@ +# VEX Archiving + +## Overview + +The VEX archive feature in CVE Binary Tool allows you to archive obsolete VEX (Vulnerability Exploitability eXchange) triage information when components are updated or removed from your software project. This ensures that your VEX files remain current while preserving historical triage decisions for audit purposes. + +## What is VEX Archiving? + +When software components are updated or removed, previously analyzed vulnerabilities in your VEX files may become obsolete. The VEX archive feature automatically: + +1. **Identifies obsolete entries** - Finds VEX entries for components no longer present in your current scan +2. **Archives obsolete data** - Moves outdated triage information to separate archive files +3. **Preserves active entries** - Keeps current and relevant VEX entries in the main file +4. **Creates backups** - Safely backs up original files before making changes +5. **Provides audit trails** - Generates detailed logs of archiving operations + +## Use Cases + +- **Software updates**: After upgrading component versions, archive outdated vulnerability assessments +- **Component removal**: When removing dependencies, archive their associated triage data +- **Compliance auditing**: Maintain historical records of security decisions while keeping current files clean +- **Team workflows**: Ensure VEX files stay synchronized with actual software composition + +## Basic Usage + +### Prerequisites + +Before using VEX archive, you need: +- A VEX file containing vulnerability triage information +- A current scan report (JSON format) from CVE Binary Tool + +### Archive Obsolete VEX Entries + +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report scan-report.json +``` + +This command will: +- Analyze your VEX file against the current scan report +- Archive any obsolete vulnerability entries +- Create backup files for safety +- Generate an archive summary report + +### Dry Run Mode + +To preview what would be archived without making changes: + +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report scan-report.json --dry-run +``` + +The dry run will show: +- Number of active vs obsolete entries +- Which components would be archived +- Expected output files +- No actual changes are made + +### Custom Archive File + +Specify a custom location for the archive file: + +```bash +cve-bin-tool vex-archive --vex my-project.vex.json --report scan-report.json --archive-file archived-entries.json +``` + +## Command Reference + +### Required Arguments + +- `--vex VEX_FILE`: Path to the VEX file to process +- `--report REPORT_FILE`: Path to the JSON scan report for comparison + +### Optional Arguments + +- `--archive-file ARCHIVE_FILE`: Custom path for archived entries (default: `vex-archive.json`) +- `--dry-run`: Preview changes without modifying files +- `--help`: Show command help and usage information + +## Output Files + +When VEX archive runs, it creates several files: + +### Archive File (`vex-archive.json`) +Contains archived obsolete entries: +```json +{ + "metadata": { + "tool": "cve-bin-tool vex-archive", + "timestamp": "2024-08-21T10:30:00Z", + "source_vex_file": "/path/to/my-project.vex.json", + "source_report_file": "/path/to/scan-report.json", + "total_archived_entries": 3 + }, + "archived_entries": [ + { + "product_id": "vendor:component:1.0.0", + "product_info": { + "vendor": "vendor", + "product": "component", + "version": "1.0.0", + "location": "/path/to/component" + }, + "vex_data": { + "CVE-2023-1234": { + "remarks": "NotAffected", + "comments": "Vulnerable code not present", + "response": [] + } + } + } + ] +} +``` + +### Backup File (`my-project.vex.json.backup`) +Complete backup of the original VEX file before any modifications. + +### Archive Summary (`my-project.vex.json.archive_note.txt`) +Human-readable summary of the archiving operation: +``` +VEX Archive Operation Summary +============================ + +Date: 2024-08-21T10:30:00Z +Original VEX file: /path/to/my-project.vex.json +Backup created: /path/to/my-project.vex.json.backup +Archive file: /path/to/vex-archive.json + +Summary: +- Total VEX entries processed: 15 +- Active entries (kept): 12 +- Obsolete entries (archived): 3 + +Archived Components: +- old-vendor:old-component:1.0.0 (2 CVE entries) +- deprecated:library:2.1.0 (1 CVE entry) + +The original VEX file has been updated to remove obsolete entries. +Historical triage data has been preserved in the archive file. +``` + +## Integration Workflows + +### CI/CD Pipeline Integration + +Integrate VEX archiving into your automated workflows: + +```bash +#!/bin/bash +# Update dependencies +npm update + +# Scan updated project +cve-bin-tool --output-file current-scan.json --format json . + +# Archive obsolete VEX entries +cve-bin-tool vex-archive --vex project.vex.json --report current-scan.json + +# Commit updated VEX file and archive +git add project.vex.json vex-archive.json +git commit -m "Archive obsolete VEX entries after dependency update" +``` + +### Manual Workflow + +For manual security reviews: + +1. **Scan current project**: + ```bash + cve-bin-tool --output-file latest-scan.json --format json . + ``` + +2. **Preview archiving**: + ```bash + cve-bin-tool vex-archive --vex security.vex.json --report latest-scan.json --dry-run + ``` + +3. **Execute archiving**: + ```bash + cve-bin-tool vex-archive --vex security.vex.json --report latest-scan.json + ``` + +4. **Review results**: + - Check the archive summary file + - Verify the updated VEX file + - Commit changes to version control + +## Best Practices + +### File Management +- **Always use version control** for VEX files and archives +- **Keep archive files** for compliance and audit purposes +- **Review archive summaries** to understand what was changed +- **Test with dry-run first** before making actual changes + +### Automation +- **Integrate with CI/CD** to automatically archive obsolete entries +- **Schedule regular archiving** after dependency updates +- **Set up notifications** when significant changes occur +- **Maintain backup retention** policies for archived data + +### Team Collaboration +- **Document archiving procedures** in your security runbooks +- **Train team members** on VEX archive workflows +- **Establish review processes** for archiving operations +- **Communicate changes** when VEX files are updated + +## Troubleshooting + +### Common Issues + +**Error: VEX file not found** +``` +Solution: Verify the VEX file path is correct and the file exists +``` + +**Error: Report file not found** +``` +Solution: Ensure the scan report exists and is in JSON format +``` + +**Error: No obsolete entries found** +``` +This is normal - it means all VEX entries are still relevant to your current scan +``` + +**Error: Permission denied** +``` +Solution: Check file permissions and write access to the output directory +``` + +### Validation + +After archiving, validate the results: + +1. **Check file integrity**: + ```bash + # Verify VEX file is still valid + cve-bin-tool --vex-file updated.vex.json + ``` + +2. **Compare before/after**: + ```bash + # Count entries before archiving + wc -l original.vex.json + + # Count entries after archiving + wc -l updated.vex.json + ``` + +3. **Review archive contents**: + ```bash + # Examine archived entries + cat vex-archive.json | jq '.archived_entries' + ``` + +## Related Documentation + +- [VEX Generation Guide](vex_generation.md) - Creating VEX files +- [Triaging Process](../triaging_process.md) - General VEX usage +- [SBOM Integration](sbom_generation.md) - Using VEX with SBOMs +- [CVE Binary Tool Manual](../MANUAL.md) - Complete command reference + +## Support + +For questions or issues with VEX archiving: +- Check the [troubleshooting section](#troubleshooting) above +- Review the [CVE Binary Tool documentation](https://cve-bin-tool.readthedocs.io/) +- Open an issue on [GitHub](https://github.com/intel/cve-bin-tool/issues) +- Join the discussion on [Gitter](https://gitter.im/cve-bin-tool/community) diff --git a/test/test_vex_archive.py b/test/test_vex_archive.py new file mode 100644 index 0000000000..8a1946e4cd --- /dev/null +++ b/test/test_vex_archive.py @@ -0,0 +1,518 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from cve_bin_tool.cli import main +from cve_bin_tool.util import ProductInfo +from cve_bin_tool.vex_manager.archive import VEXArchiveManager + + +class TestVEXArchive(unittest.TestCase): + """Test cases for VEX Archive functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = Path(tempfile.mkdtemp(prefix="test_vex_archive_")) + self.test_dir = Path(__file__).parent.resolve() + + # Use existing test VEX files + self.test_vex_file = self.test_dir / "vex" / "test_cyclonedx_vex.json" + + # Sample cve-bin-tool JSON reports + self.sample_report_mixed = { + "vulnerabilities": { + "report": [ + { + "entries": [ + { + "vendor": "vendor0", + "product": "product0", + "version": "1.0", + }, + { + "vendor": "nginx", + "product": "nginx", + "version": "1.18.0", + }, + ] + } + ] + } + } + + self.sample_report_all_active = { + "vulnerabilities": { + "report": [ + { + "entries": [ + { + "vendor": "vendor0", + "product": "product0", + "version": "1.0", + }, + { + "vendor": "vendor0", + "product": "product0", + "version": "2.8.6", + }, + ] + } + ] + } + } + + self.sample_report_all_obsolete = { + "vulnerabilities": { + "report": [ + { + "entries": [ + {"vendor": "nginx", "product": "nginx", "version": "1.18.0"} + ] + } + ] + } + } + + def tearDown(self): + """Clean up test fixtures.""" + import shutil + + if self.temp_dir.exists(): + shutil.rmtree(self.temp_dir) + + def _create_test_files(self, vex_content, report_content): + """Helper to create test VEX and report files.""" + vex_file = self.temp_dir / "test_vex.json" + report_file = self.temp_dir / "test_report.json" + + # Handle VEX content - either dict for custom content or None for real file + if vex_content is None: + # Copy real VEX file from test data + vex_file_path = Path(__file__).parent / "vex" / "test_cyclonedx_vex.json" + shutil.copy2(vex_file_path, vex_file) + else: + # Create VEX file with custom content + with open(vex_file, "w", encoding="utf-8") as f: + json.dump(vex_content, f, indent=2) + + # Create report file + with open(report_file, "w", encoding="utf-8") as f: + json.dump(report_content, f, indent=2) + + return str(vex_file), str(report_file) + + def test_load_vex_file(self): + """Test VEX file loading with different formats.""" + vex_file, _ = self._create_test_files(None, {}) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path="/dev/null", dry_run=True + ) + + # Should load without error + archive_manager.load_vex_file() + self.assertTrue(len(archive_manager.vex_data) > 0) + + def test_load_vex_file_missing(self): + """Test error handling for missing VEX file.""" + archive_manager = VEXArchiveManager( + vex_file_path="/nonexistent/file.json", + report_file_path="/dev/null", + dry_run=True, + ) + + with self.assertRaises(FileNotFoundError): + archive_manager.load_vex_file() + + def test_load_report_file(self): + """Test JSON report loading and product extraction.""" + _, report_file = self._create_test_files(None, self.sample_report_mixed) + + archive_manager = VEXArchiveManager( + vex_file_path="/dev/null", report_file_path=report_file, dry_run=True + ) + + archive_manager.load_report_file() + self.assertEqual(len(archive_manager.current_products), 2) + self.assertIn("vendor0:product0:1.0", archive_manager.current_products) + self.assertIn("nginx:nginx:1.18.0", archive_manager.current_products) + + def test_load_report_file_missing(self): + """Test error handling for missing report file.""" + archive_manager = VEXArchiveManager( + vex_file_path="/dev/null", + report_file_path="/nonexistent/file.json", + dry_run=True, + ) + + with self.assertRaises(FileNotFoundError): + archive_manager.load_report_file() + + def test_analyze_entries_all_obsolete(self): + """Test when all VEX entries are obsolete.""" + vex_file, report_file = self._create_test_files( + None, self.sample_report_all_obsolete + ) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=True + ) + + archive_manager.load_vex_file() + archive_manager.load_report_file() + active_count, obsolete_count = archive_manager.analyze_entries() + + self.assertEqual(active_count, 0) + self.assertTrue(obsolete_count > 0) # Some VEX entries should be obsolete + + def test_analyze_entries_all_active(self): + """Test when all VEX entries are still active.""" + vex_file, report_file = self._create_test_files( + None, self.sample_report_all_active + ) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=True + ) + + archive_manager.load_vex_file() + archive_manager.load_report_file() + active_count, obsolete_count = archive_manager.analyze_entries() + + self.assertTrue(active_count > 0) # Some VEX entries should be active + self.assertEqual(obsolete_count, 0) + + def test_analyze_entries_mixed(self): + """Test mixed scenario (some active, some obsolete).""" + vex_file, report_file = self._create_test_files(None, self.sample_report_mixed) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=True + ) + + archive_manager.load_vex_file() + archive_manager.load_report_file() + active_count, obsolete_count = archive_manager.analyze_entries() + + self.assertTrue(active_count > 0) # Some VEX entries should be active + self.assertTrue(obsolete_count > 0) # Some VEX entries should be obsolete + + def test_dry_run_mode(self): + """Test dry run doesn't create files but shows results.""" + vex_file, report_file = self._create_test_files(None, self.sample_report_mixed) + + archive_file = self.temp_dir / "archive.json" + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, + report_file_path=report_file, + archive_file_path=str(archive_file), + dry_run=True, + ) + + result = archive_manager.process() + + # Dry run should succeed + self.assertTrue(result["success"]) + self.assertTrue(result["dry_run"]) + + # No files should be created + self.assertFalse(archive_file.exists()) + self.assertFalse(Path(vex_file + ".backup").exists()) + + def test_archive_creation(self): + """Test archive.json file content and structure.""" + vex_file, report_file = self._create_test_files(None, self.sample_report_mixed) + + archive_file = self.temp_dir / "archive.json" + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, + report_file_path=report_file, + archive_file_path=str(archive_file), + dry_run=False, + ) + + result = archive_manager.process() + + # Archive should be successful + if not result["success"]: + error_msg = result.get("error", "Unknown error") + self.fail(f"VEX archive process failed: {error_msg}") + self.assertTrue(result["success"]) + + # If there are obsolete entries, check archive structure + if result["obsolete_entries"] > 0: + self.assertTrue(archive_file.exists()) + + with open(archive_file, encoding="utf-8") as f: + archive_data = json.load(f) + + self.assertIn("metadata", archive_data) + self.assertIn("archived_entries", archive_data) + self.assertEqual( + archive_data["metadata"]["total_archived_entries"], + result["obsolete_entries"], + ) + + def test_backup_creation(self): + """Test that backup and summary files are created.""" + vex_file, report_file = self._create_test_files(None, self.sample_report_mixed) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=False + ) + + result = archive_manager.process() + + # Process should succeed + if not result["success"]: + error_msg = result.get("error", "Unknown error") + self.fail(f"VEX archive process failed: {error_msg}") + self.assertTrue(result["success"]) + + # If obsolete entries were found, backup files should exist + if result["obsolete_entries"] > 0: + backup_file = Path(vex_file + ".backup") + summary_file = Path(vex_file + ".archive_note.txt") + + self.assertTrue(backup_file.exists()) + self.assertTrue(summary_file.exists()) + + # Check summary content + with open(summary_file, encoding="utf-8") as f: + summary_content = f.read() + + self.assertIn("VEX Archive Operation Summary", summary_content) + + def test_empty_vex_file(self): + """Test behavior with empty VEX file.""" + # Create empty VEX content + empty_vex = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "vulnerabilities": [], + } + + vex_file, report_file = self._create_test_files( + empty_vex, self.sample_report_mixed + ) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=True + ) + + # Should handle empty VEX gracefully + result = archive_manager.process() + self.assertTrue(result["success"]) + self.assertEqual(result["obsolete_entries"], 0) + + def test_empty_report_file(self): + """Test behavior with empty report file.""" + empty_report = {"vulnerabilities": {"report": []}} + vex_file, report_file = self._create_test_files(None, empty_report) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=True + ) + + result = archive_manager.process() + self.assertTrue(result["success"]) + # Most/all VEX entries should be obsolete since report is empty + self.assertTrue(result["obsolete_entries"] >= 0) + + def test_malformed_json(self): + """Test error handling for malformed JSON files.""" + # Create malformed JSON file + bad_file = self.temp_dir / "bad.json" + with open(bad_file, "w", encoding="utf-8") as f: + f.write("{ invalid json }") + + vex_file, _ = self._create_test_files(None, {}) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=str(bad_file), dry_run=True + ) + + result = archive_manager.process() + self.assertFalse(result["success"]) + self.assertIn("error", result) + + def test_product_info_to_id_conversion(self): + """Test ProductInfo to vendor:product:version conversion.""" + archive_manager = VEXArchiveManager( + vex_file_path="/dev/null", report_file_path="/dev/null", dry_run=True + ) + + # Test normal case + product_info = ProductInfo("apache", "httpd", "2.4.41", "/usr/bin/httpd") + product_id = archive_manager._product_info_to_id(product_info) + self.assertEqual(product_id, "apache:httpd:2.4.41") + + # Test with None values + product_info_none = ProductInfo(None, "test", None, "/path") + product_id_none = archive_manager._product_info_to_id(product_info_none) + self.assertEqual(product_id_none, "unknown:test:") + + def test_full_workflow_with_obsolete_entries(self): + """End-to-end test with obsolete entries.""" + # Create VEX content with entries that match our test report data + vex_content = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "vulnerabilities": [ + { + "id": "CVE-2021-1234", + "analysis": {"state": "not_affected"}, + "affects": [ + {"ref": "urn:cbt:1/vendor0#product0:1.0"} + ], # This matches sample_report_mixed + }, + { + "id": "CVE-2022-5678", + "analysis": {"state": "in_triage"}, + "affects": [ + {"ref": "urn:cbt:1/nginx#nginx:1.18.0"} + ], # This matches sample_report_mixed + }, + { + "id": "CVE-2023-9999", # This one will be obsolete (not in report) + "analysis": {"state": "not_affected"}, + "affects": [{"ref": "urn:cbt:1/oldvendor#oldpkg:1.5.0"}], + }, + ], + } + + vex_file, report_file = self._create_test_files( + vex_content, self.sample_report_mixed + ) + + archive_file = self.temp_dir / "test_archive.json" + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, + report_file_path=report_file, + archive_file_path=str(archive_file), + dry_run=False, + ) + + result = archive_manager.process() + + # Verify complete workflow + if not result["success"]: + error_msg = result.get("error", "Unknown error") + self.fail(f"VEX archive process failed: {error_msg}") + self.assertTrue(result["success"]) + self.assertEqual( + result["active_entries"], 2 + ) # Both vendor0:product0:1.0 and nginx:nginx:1.18.0 are active + self.assertEqual( + result["obsolete_entries"], 1 + ) # oldvendor:oldpkg:1.5.0 is obsolete + self.assertTrue(result["vex_file_updated"]) + + # Verify all expected files exist + self.assertTrue(archive_file.exists()) + self.assertTrue(Path(vex_file + ".backup").exists()) + self.assertTrue(Path(vex_file + ".archive_note.txt").exists()) + + def test_full_workflow_no_obsolete_entries(self): + """End-to-end test with no obsolete entries.""" + # Create VEX content with entries that match our test report data + vex_content = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "vulnerabilities": [ + { + "id": "CVE-2021-1234", + "analysis": {"state": "not_affected"}, + "affects": [ + {"ref": "urn:cbt:1/vendor0#product0:1.0"} + ], # Matches sample_report_all_active + }, + { + "id": "CVE-2022-5678", + "analysis": {"state": "not_affected"}, + "affects": [ + {"ref": "urn:cbt:1/vendor0#product0:2.8.6"} + ], # Matches sample_report_all_active + }, + ], + } + + vex_file, report_file = self._create_test_files( + vex_content, self.sample_report_all_active + ) + + archive_manager = VEXArchiveManager( + vex_file_path=vex_file, report_file_path=report_file, dry_run=False + ) + + result = archive_manager.process() + + # Verify workflow with no changes needed + self.assertTrue(result["success"]) + self.assertEqual(result["active_entries"], 2) + self.assertEqual(result["obsolete_entries"], 0) + self.assertFalse(result["vex_file_updated"]) + + +class TestVEXArchiveCLI(unittest.TestCase): + """Test cases for VEX Archive CLI integration.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = Path(tempfile.mkdtemp(prefix="test_vex_archive_cli_")) + + def tearDown(self): + """Clean up test fixtures.""" + import shutil + + if self.temp_dir.exists(): + shutil.rmtree(self.temp_dir) + + def test_vex_archive_help(self): + """Test that vex-archive help works.""" + with patch("sys.argv", ["cve-bin-tool", "vex-archive", "--help"]): + with self.assertRaises(SystemExit) as cm: + main() + # Help should exit with code 0 + self.assertEqual(cm.exception.code, 0) + + def test_vex_archive_missing_args(self): + """Test error when required args are missing.""" + with patch("sys.argv", ["cve-bin-tool", "vex-archive"]): + with self.assertRaises(SystemExit) as cm: + main() + # Should exit with error code for missing required arguments + self.assertNotEqual(cm.exception.code, 0) + + def test_vex_archive_missing_files(self): + """Test error handling for non-existent files.""" + with patch( + "sys.argv", + [ + "cve-bin-tool", + "vex-archive", + "--vex", + "/nonexistent/vex.json", + "--report", + "/nonexistent/report.json", + ], + ): + result = main() + # Should return error code for missing files + self.assertNotEqual(result, 0) + + +if __name__ == "__main__": + unittest.main()