Files
git.stella-ops.org/.gitea/workflows/templates/vex-mapping-check.yml
2026-02-19 22:10:54 +02:00

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