Skip to content

Commit bc361f6

Browse files
committed
fix: CLI argument parsing for vex-validate
1 parent 2a40dd3 commit bc361f6

12 files changed

+910
-60
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ For more details, see our [documentation](https://cve-bin-tool.readthedocs.io/en
4747
- [Scanning an SBOM file for known vulnerabilities](#scanning-an-sbom-file-for-known-vulnerabilities)
4848
- [Generating an SBOM](#generating-an-sbom)
4949
- [Generating a VEX](#generating-a-vex)
50+
- [Validating VEX Files](#validating-vex-files)
5051
- [Triaging vulnerabilities](#triaging-vulnerabilities)
5152
- [Using the tool offline](#using-the-tool-offline)
5253
- [Using CVE Binary Tool in GitHub Actions](#using-cve-binary-tool-in-github-actions)
@@ -134,6 +135,22 @@ Valid VEX types are [CSAF](https://oasis-open.github.io/csaf-documentation/), [C
134135

135136
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.
136137

138+
### Validating VEX Files
139+
140+
CVE Binary Tool includes a comprehensive VEX validation tool that ensures your VEX documents are correctly formatted and contain accurate vulnerability information:
141+
142+
```bash
143+
cve-bin-tool vex-validate --vex-file-to-validate <vex_filename>
144+
```
145+
146+
The validation tool supports all VEX formats (CycloneDX, CSAF, OpenVEX) and provides:
147+
- Schema compliance checking
148+
- Status transition validation
149+
- Missing field detection
150+
- Actionable error messages with specific fixes
151+
152+
The [VEX validation how-to guide](https://github.com/intel/cve-bin-tool/blob/main/doc/how_to_guides/vex_validation.md) provides detailed validation examples and troubleshooting information.
153+
137154
### Triaging vulnerabilities
138155

139156
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`).

cve_bin_tool/cli.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@
7979
from cve_bin_tool.version import VERSION
8080
from cve_bin_tool.version_scanner import VersionScanner
8181
from cve_bin_tool.vex_manager.parse import VEXParse
82-
from cve_bin_tool.vex_manager.validate import validate_vex_file
8382

8483
sys.excepthook = excepthook # Always install excepthook for entrypoint module.
8584

@@ -174,6 +173,14 @@ def main(argv=None):
174173
default="",
175174
)
176175

176+
# Add command argument before directory to ensure proper parsing order
177+
parser.add_argument(
178+
"command",
179+
nargs="?",
180+
choices=["vex-validate"],
181+
help="Command to run: vex-validate to validate VEX files",
182+
)
183+
177184
input_group = parser.add_argument_group("Input")
178185
input_group.add_argument(
179186
"directory", help="directory to scan", nargs="?", default=""
@@ -575,18 +582,6 @@ def main(argv=None):
575582
default=False,
576583
)
577584

578-
parser.add_argument(
579-
"command",
580-
nargs="?",
581-
choices=["vex-validate"],
582-
help="Command to run: vex-validate to validate VEX files",
583-
)
584-
585-
# Change directory to be optional when using commands
586-
input_group.add_argument(
587-
"directory", help="directory to scan", nargs="?", default=""
588-
)
589-
590585
with ErrorHandler(mode=ErrorMode.NoTrace):
591586
raw_args = parser.parse_args(argv[1:])
592587
args = {key: value for key, value in vars(raw_args).items() if value}
@@ -602,8 +597,12 @@ def main(argv=None):
602597
# Use directory as file path if vex_file_to_validate not provided
603598
vex_file_path = raw_args.vex_file_to_validate or raw_args.directory
604599

605-
# Import and run validation
606-
exit_code = validate_vex_file(vex_file_path, offline=args.get("offline", False))
600+
# Import and run validation locally to avoid scope issues
601+
from cve_bin_tool.vex_manager.validate import validate_vex_file
602+
603+
exit_code = validate_vex_file(
604+
vex_file_path, logger=None, offline=args.get("offline", False)
605+
)
607606
return exit_code
608607

609608
configs = {}

cve_bin_tool/vex_manager/validate.py

Lines changed: 216 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
"""
55
VEX Validation Module
66
7-
Provides functionality to validate VEX files for schema compliance using standard
8-
JSON schemas for CycloneDX, CSAF, and OpenVEX formats.
7+
Provides functionality to validate VEX files for schema compliance using
8+
lib4sbom for CycloneDX validation and standard JSON schemas for CSAF and OpenVEX formats.
99
"""
1010

1111
import json
@@ -16,6 +16,8 @@
1616

1717
import jsonschema
1818
from jsonschema import ValidationError as JsonSchemaValidationError
19+
from lib4sbom.validator import SBOMValidator
20+
from lib4vex.parser import VEXParser
1921

2022
from cve_bin_tool.log import LOGGER
2123

@@ -161,33 +163,67 @@ def validate_file(
161163
self.logger.info(f"Detected VEX format: {vex_type} (version: {version})")
162164

163165
# Validate against appropriate schema
164-
try:
165-
schema = self._get_schema(vex_type, version)
166-
if schema: # Only validate if schema was successfully loaded
167-
jsonschema.validate(instance=raw_data, schema=schema)
168-
self.logger.info(f"VEX file passed {vex_type} schema validation")
169-
except JsonSchemaValidationError as e:
170-
# Convert jsonschema validation error to our format
171-
path = ".".join(str(p) for p in e.path) if e.path else None
166+
if vex_type == "cyclonedx":
167+
# Use lib4sbom validator for CycloneDX VEX files
168+
try:
169+
sbom_validator = SBOMValidator()
170+
validation_result = sbom_validator.validate_file(vex_file_path)
171+
172+
# Check validation result
173+
cyclonedx_valid = validation_result.get("CycloneDX", False)
174+
if not cyclonedx_valid:
175+
self.errors.append(
176+
ValidationError(
177+
"SCHEMA_ERROR",
178+
"CycloneDX schema validation failed. File does not comply with CycloneDX specification.",
179+
)
180+
)
181+
else:
182+
self.logger.info(
183+
"VEX file passed CycloneDX schema validation using lib4sbom"
184+
)
172185

173-
self.errors.append(
174-
ValidationError(
175-
"SCHEMA_ERROR",
176-
f"Schema validation error: {e.message}",
177-
field=path,
178-
location=str(e.schema_path) if e.schema_path else None,
186+
except Exception as e:
187+
self.errors.append(
188+
ValidationError(
189+
"VALIDATION_ERROR",
190+
f"Error during CycloneDX validation: {str(e)}",
191+
)
179192
)
180-
)
181-
except Exception as e:
182-
self.errors.append(
183-
ValidationError(
184-
"VALIDATION_ERROR", f"Error during schema validation: {str(e)}"
193+
else:
194+
# Use traditional jsonschema validation for CSAF and OpenVEX
195+
try:
196+
schema = self._get_schema(vex_type, version)
197+
if schema: # Only validate if schema was successfully loaded
198+
jsonschema.validate(instance=raw_data, schema=schema)
199+
self.logger.info(f"VEX file passed {vex_type} schema validation")
200+
except JsonSchemaValidationError as e:
201+
# Convert jsonschema validation error to our format
202+
path = ".".join(str(p) for p in e.path) if e.path else None
203+
204+
self.errors.append(
205+
ValidationError(
206+
"SCHEMA_ERROR",
207+
f"Schema validation error: {e.message}",
208+
field=path,
209+
location=str(e.schema_path) if e.schema_path else None,
210+
)
211+
)
212+
except Exception as e:
213+
self.errors.append(
214+
ValidationError(
215+
"VALIDATION_ERROR", f"Error during schema validation: {str(e)}"
216+
)
185217
)
186-
)
187218

188219
# Perform format-specific validations
189220
self._perform_format_specific_validation(raw_data, vex_type)
190221

222+
# Validate status transitions and provide actionable fixes using lib4vex
223+
self._validate_status_transitions_and_suggest_fixes(
224+
raw_data, vex_type, vex_file_path
225+
)
226+
191227
# Add warnings for empty sections
192228
self._check_for_empty_sections(raw_data, vex_type)
193229

@@ -463,6 +499,164 @@ def _validate_csaf_vulnerability(self, vulnerability: Dict, index: int) -> None:
463499
)
464500
)
465501

502+
def _validate_status_transitions_and_suggest_fixes(
503+
self, raw_data: Dict, vex_type: str, vex_file_path: str
504+
) -> None:
505+
"""
506+
Validate status transitions and provide actionable fixes using lib4vex.
507+
508+
Checks for invalid status transitions (e.g., marking a resolved CVE as not_affected
509+
without justification) and uses lib4vex to suggest improvements.
510+
"""
511+
# For CycloneDX, use basic validation since lib4sbom handles schema validation
512+
if vex_type == "cyclonedx":
513+
self._validate_status_transitions_basic(raw_data, vex_type)
514+
self._suggest_actionable_fixes(raw_data, vex_type)
515+
return
516+
517+
# For other formats, try lib4vex first, then fall back to basic validation
518+
try:
519+
# Use lib4vex parser to analyze the VEX file
520+
vex_parser = VEXParser()
521+
vex_parser.parse(vex_file_path)
522+
523+
# Get vulnerabilities from lib4vex parser
524+
vulnerabilities = vex_parser.get_vulnerabilities()
525+
526+
for vuln in vulnerabilities:
527+
self._validate_individual_vulnerability_status(vuln, vex_type)
528+
529+
# Check for missing required fields and suggest fixes
530+
self._suggest_actionable_fixes(raw_data, vex_type)
531+
532+
except Exception as e:
533+
self.logger.debug(f"lib4vex validation failed: {str(e)}")
534+
# Fall back to basic validation if lib4vex fails
535+
self._validate_status_transitions_basic(raw_data, vex_type)
536+
self._suggest_actionable_fixes(raw_data, vex_type)
537+
538+
def _validate_individual_vulnerability_status(
539+
self, vuln: Dict, vex_type: str
540+
) -> None:
541+
"""Validate individual vulnerability status transitions."""
542+
status = vuln.get("status", "").lower()
543+
544+
# Check for invalid status transitions
545+
if status == "not_affected":
546+
# If status is not_affected, should have justification
547+
justification = (
548+
vuln.get("justification")
549+
or vuln.get("detail")
550+
or vuln.get("action_statement")
551+
)
552+
if not justification:
553+
self.errors.append(
554+
ValidationError(
555+
"INVALID_STATUS_TRANSITION",
556+
"CVE marked as 'not_affected' without justification. Add justification field explaining why this vulnerability does not affect the product.",
557+
field="justification",
558+
location=f"vulnerability {vuln.get('id', 'unknown')}",
559+
)
560+
)
561+
562+
elif status == "resolved" or status == "fixed":
563+
# If status is resolved/fixed, should have timestamp and details
564+
timestamp = vuln.get("updated") or vuln.get("timestamp")
565+
if not timestamp:
566+
self.warnings.append(
567+
ValidationError(
568+
"MISSING_TIMESTAMP",
569+
"Fixed/resolved vulnerability should have a timestamp indicating when it was resolved. Add timestamp field.",
570+
field="timestamp",
571+
location=f"vulnerability {vuln.get('id', 'unknown')}",
572+
severity="warning",
573+
)
574+
)
575+
576+
def _validate_status_transitions_basic(self, raw_data: Dict, vex_type: str) -> None:
577+
"""Basic status transition validation when lib4vex is not available."""
578+
if vex_type == "cyclonedx":
579+
vulnerabilities = raw_data.get("vulnerabilities", [])
580+
for i, vuln in enumerate(vulnerabilities):
581+
analysis = vuln.get("analysis", {})
582+
state = analysis.get("state", "").lower()
583+
584+
if state == "not_affected" and not analysis.get("detail"):
585+
self.errors.append(
586+
ValidationError(
587+
"INVALID_STATUS_TRANSITION",
588+
"CVE marked as 'not_affected' without justification in analysis.detail field.",
589+
field="analysis.detail",
590+
location=f"vulnerabilities[{i}]",
591+
)
592+
)
593+
594+
elif vex_type == "openvex":
595+
statements = raw_data.get("statements", [])
596+
for i, stmt in enumerate(statements):
597+
status = stmt.get("status", "").lower()
598+
599+
if status == "not_affected" and not stmt.get("action_statement"):
600+
self.errors.append(
601+
ValidationError(
602+
"INVALID_STATUS_TRANSITION",
603+
"Statement marked as 'not_affected' without action_statement explaining why.",
604+
field="action_statement",
605+
location=f"statements[{i}]",
606+
)
607+
)
608+
609+
def _suggest_actionable_fixes(self, raw_data: Dict, vex_type: str) -> None:
610+
"""Suggest actionable fixes for common VEX file issues."""
611+
612+
# Check for missing timestamp in document metadata
613+
if vex_type == "cyclonedx":
614+
metadata = raw_data.get("metadata", {})
615+
if not metadata.get("timestamp"):
616+
self.warnings.append(
617+
ValidationError(
618+
"MISSING_FIELD",
619+
"Add missing timestamp field to metadata for better traceability: 'timestamp': '2024-01-01T00:00:00Z'",
620+
field="metadata.timestamp",
621+
severity="warning",
622+
)
623+
)
624+
625+
elif vex_type == "openvex":
626+
if not raw_data.get("timestamp"):
627+
self.warnings.append(
628+
ValidationError(
629+
"MISSING_FIELD",
630+
"Add missing timestamp field: 'timestamp': '2024-01-01T00:00:00'",
631+
field="timestamp",
632+
severity="warning",
633+
)
634+
)
635+
636+
# Check for missing author information
637+
if vex_type == "cyclonedx":
638+
metadata = raw_data.get("metadata", {})
639+
if not metadata.get("authors") and not metadata.get("supplier"):
640+
self.warnings.append(
641+
ValidationError(
642+
"MISSING_FIELD",
643+
"Add author information in metadata.authors or metadata.supplier for accountability",
644+
field="metadata.authors",
645+
severity="warning",
646+
)
647+
)
648+
649+
elif vex_type == "openvex":
650+
if not raw_data.get("author"):
651+
self.warnings.append(
652+
ValidationError(
653+
"MISSING_FIELD",
654+
"Add missing author field: 'author': 'Your Organization'",
655+
field="author",
656+
severity="warning",
657+
)
658+
)
659+
466660
def _check_for_empty_sections(self, raw_data: Dict, vex_type: str) -> None:
467661
"""Check for empty vulnerability/statement sections and add warnings."""
468662
if vex_type == "cyclonedx":

0 commit comments

Comments
 (0)