168 lines
6.3 KiB
YAML
168 lines
6.3 KiB
YAML
# =============================================================================
|
|
# 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
|