# ============================================================================= # vex-mapping-check.yml # Sprint: SPRINT_20260219_011 (CIAP-03) # Description: Validates VEX documents and verifies target artifact matching # ============================================================================= # # This workflow validates OpenVEX or CycloneDX VEX documents against their # schemas, asserts required fields are present and valid, and optionally # verifies target artifact matches a known canonical_id. # # ============================================================================= name: VEX Mapping Check on: workflow_call: inputs: vex_path: description: 'Path to VEX document (JSON)' required: true type: string vex_format: description: 'VEX format: openvex or cyclonedx' required: false type: string default: 'openvex' canonical_id: description: 'Expected canonical_id of the target artifact (optional)' required: false type: string schema_path: description: 'Path to VEX JSON schema (optional, uses bundled schemas by default)' required: false type: string jobs: validate-vex: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Validate VEX schema id: validate run: | VEX_FILE="${{ inputs.vex_path }}" FORMAT="${{ inputs.vex_format }}" # Select schema if [ -n "${{ inputs.schema_path }}" ]; then SCHEMA="${{ inputs.schema_path }}" elif [ "${FORMAT}" = "openvex" ]; then SCHEMA="docs/schemas/openvex-0.2.0.schema.json" else SCHEMA="docs/schemas/cyclonedx-bom-1.7.schema.json" fi # Validate if [ -f "${SCHEMA}" ]; then npx ajv-cli validate -s "${SCHEMA}" -d "${VEX_FILE}" && { echo "Schema validation: PASS" >> $GITHUB_STEP_SUMMARY } || { echo "Schema validation: FAIL" >> $GITHUB_STEP_SUMMARY exit 1 } else echo "Schema file not found: ${SCHEMA}, skipping schema validation" >> $GITHUB_STEP_SUMMARY fi - name: Assert required VEX fields run: | FORMAT="${{ inputs.vex_format }}" VEX_FILE="${{ inputs.vex_path }}" python3 -c " import json, sys with open('${VEX_FILE}') as f: vex = json.load(f) errors = [] format_name = '${FORMAT}' if format_name == 'openvex': # OpenVEX validation if 'statements' not in vex: errors.append('Missing required field: statements') else: for i, stmt in enumerate(vex['statements']): if 'status' not in stmt: errors.append(f'Statement [{i}]: missing status') elif stmt['status'] not in ('affected', 'not_affected', 'fixed', 'under_investigation'): errors.append(f'Statement [{i}]: invalid status: {stmt[\"status\"]}') if 'vulnerability' not in stmt: errors.append(f'Statement [{i}]: missing vulnerability') if 'product' not in stmt and 'products' not in stmt: errors.append(f'Statement [{i}]: missing product or products') else: # CycloneDX VEX (embedded in SBOM vulnerabilities) vulns = vex.get('vulnerabilities', []) if not vulns: errors.append('No vulnerabilities found in CycloneDX VEX') for i, vuln in enumerate(vulns): analysis = vuln.get('analysis', {}) state = analysis.get('state') if not state: errors.append(f'Vulnerability [{i}] ({vuln.get(\"id\",\"?\")}): missing analysis.state') elif state not in ('resolved', 'resolved_with_pedigree', 'exploitable', 'in_triage', 'false_positive', 'not_affected'): errors.append(f'Vulnerability [{i}]: invalid analysis.state: {state}') if errors: print('VEX field validation FAILED:', file=sys.stderr) for e in errors: print(f' - {e}', file=sys.stderr) sys.exit(1) else: print(f'VEX field validation: PASS ({format_name})') " echo "VEX field assertions: PASS" >> $GITHUB_STEP_SUMMARY - name: Verify target canonical_id match if: inputs.canonical_id != '' run: | FORMAT="${{ inputs.vex_format }}" VEX_FILE="${{ inputs.vex_path }}" EXPECTED_ID="${{ inputs.canonical_id }}" python3 -c " import json, sys with open('${VEX_FILE}') as f: vex = json.load(f) expected = '${EXPECTED_ID}' format_name = '${FORMAT}' found_match = False if format_name == 'openvex': for stmt in vex.get('statements', []): product = stmt.get('product', '') products = stmt.get('products', []) targets = [product] if product else products for t in targets: pid = t if isinstance(t, str) else t.get('@id', '') if expected.replace('sha256:', '') in pid or pid == expected: found_match = True break else: # CycloneDX: check affects refs for vuln in vex.get('vulnerabilities', []): for affects in vuln.get('affects', []): ref = affects.get('ref', '') if expected.replace('sha256:', '') in ref: found_match = True break if not found_match: print(f'WARNING: canonical_id {expected} not found in VEX targets', file=sys.stderr) print('This may indicate the VEX document does not apply to the expected artifact') # Warning only, not a hard failure else: print(f'Target canonical_id match: PASS') " echo "Target artifact check: completed" >> $GITHUB_STEP_SUMMARY