Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions .github/workflows/advisory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
name: Advisory catalogue

# Validates the committed CSAF/VEX catalogue (records + overlay) and the
# publish layout. Generated documents are built in CI, not committed.
# Every advisories/releases/<version>/ directory is a release; the workflow
# does not name 5.9.1 or 5.9.2.

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
unit:
name: advisory unit tests
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.x'

- name: Syntax-check advisory tools
run: |
python3 -m py_compile \
central/gen-advisory \
central/advisory-completeness \
central/csaf-publish \
central/csaf-verify \
central/csaf-keygen \
central/test_gen_advisory.py \
central/test_advisory_completeness.py \
central/test_csaf_publish.py

- name: Unit tests
run: |
python3 -m unittest \
central/test_gen_advisory.py \
central/test_advisory_completeness.py \
central/test_csaf_publish.py

- name: Completeness gate for every release directory
run: |
python3 - <<'PY'
import subprocess, sys
import importlib.util
from importlib.machinery import SourceFileLoader
loader = SourceFileLoader('ac', 'central/advisory-completeness')
spec = importlib.util.spec_from_loader('ac', loader)
ac = importlib.util.module_from_spec(spec)
loader.exec_module(ac)
dirs = ac.list_release_dirs('advisories/releases')
if not dirs:
sys.exit('ERROR: no advisories/releases/<version>/ directories')
rc = 0
for d in dirs:
print(f'--- {d} ---')
r = subprocess.run([sys.executable, 'central/advisory-completeness',
'--release-dir', str(d)])
rc |= r.returncode
sys.exit(rc)
PY

catalogue:
name: generate and validate all releases
runs-on: ubuntu-24.04
needs: unit
timeout-minutes: 25
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.x'

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install validators
run: |
python3 -m pip install --user -r tools/csaf-validate/requirements.txt
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
npm ci --ignore-scripts --prefix tools/csaf-validate

- name: Real overlay matches its JSON Schema
run: |
python3 - <<'PY'
import json, jsonschema
schema = json.load(open('central/advisory-vex-overlay.schema.json'))
overlay = json.load(open('advisories/vex-overlay.json'))
jsonschema.Draft202012Validator.check_schema(schema)
jsonschema.Draft202012Validator(schema).validate(overlay)
print('OK: advisories/vex-overlay.json matches schema')
PY

- name: Generate CSAF + CycloneDX VEX for every release
run: |
python3 - <<'PY'
import os, pathlib, shutil, subprocess, sys
import importlib.util
from importlib.machinery import SourceFileLoader
loader = SourceFileLoader('ac', 'central/advisory-completeness')
spec = importlib.util.spec_from_loader('ac', loader)
ac = importlib.util.module_from_spec(spec)
loader.exec_module(ac)
adv = pathlib.Path(os.environ['RUNNER_TEMP']) / 'adv'
rec_root = pathlib.Path(os.environ['RUNNER_TEMP']) / 'rec'
adv.mkdir(parents=True)
records = pathlib.Path('advisories/records')
dirs = ac.list_release_dirs('advisories/releases')
if not dirs:
sys.exit('ERROR: no release directories')
for d in dirs:
pin = ac.load_cve_list(d / 'cves')
rec = rec_root / d.name
rec.mkdir(parents=True)
for cve in pin:
src = records / f'{cve}.json'
if not src.is_file():
sys.exit(f'ERROR: missing record {src}')
shutil.copy(src, rec / src.name)
subprocess.check_call([
sys.executable, 'central/gen-advisory',
'--records-dir', str(rec),
'--vex-overlay', 'advisories/vex-overlay.json',
'--out-dir', str(adv),
])
subprocess.check_call([
sys.executable, 'central/gen-advisory',
'--records-dir', str(rec),
'--vex-overlay', 'advisories/vex-overlay.json',
'--advisory-id', f'wolfssl-{d.name}',
'--csaf-out', str(adv / f'wolfssl-{d.name}.csaf.json'),
'--cdx-vex-out', str(adv / f'wolfssl-{d.name}.cdx.json'),
])
print(f'OK: generated {d.name} ({len(pin)} CVEs)')
with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as fh:
fh.write(f'ADV={adv}\n')
PY

- name: Bundle membership matches each pin list
run: |
python3 - <<'PY'
import json, os, pathlib, sys
import importlib.util
from importlib.machinery import SourceFileLoader
loader = SourceFileLoader('ac', 'central/advisory-completeness')
spec = importlib.util.spec_from_loader('ac', loader)
ac = importlib.util.module_from_spec(spec)
loader.exec_module(ac)
adv = pathlib.Path(os.environ['ADV'])
for d in ac.list_release_dirs('advisories/releases'):
pin = ac.load_cve_list(d / 'cves')
doc = json.loads((adv / f'wolfssl-{d.name}.csaf.json').read_text())
got = [v['cve'] for v in doc['vulnerabilities']]
if set(got) != set(pin) or len(got) != len(pin):
print('pin', pin, file=sys.stderr)
print('got', got, file=sys.stderr)
sys.exit(f'ERROR: bundle CVE set does not match {d.name}')
print(f'OK: wolfssl-{d.name} has {len(got)} CVEs')
PY

- name: CSAF 2.0 strict schema + mandatory tests
run: node tools/csaf-validate/csaf_validate.mjs "${ADV}"/*.csaf.json

- name: csaf_validate runner contract
run: |
VALID=$(ls "${ADV}"/CVE-*.csaf.json | head -n 1)
node tools/csaf-validate/test_csaf_validate.mjs "${VALID}"

- name: CycloneDX 1.6 strict schema and CVSS v4 ratings
run: |
python3 - <<'PY'
import glob, json, os, sys
from cyclonedx.validation.json import JsonStrictValidator
from cyclonedx.schema import SchemaVersion
v = JsonStrictValidator(SchemaVersion.V1_6)
paths = sorted(glob.glob(os.environ['ADV'] + '/*.cdx.json'))
assert paths, 'no CycloneDX VEX documents were generated'
missing = []
for p in paths:
text = open(p).read()
errs = v.validate_str(text)
if errs:
print(f'INVALID: {p}: {errs}', file=sys.stderr)
sys.exit(1)
bom = json.loads(text)
for vuln in bom.get('vulnerabilities') or []:
ratings = vuln.get('ratings') or []
if not any(r.get('method') == 'CVSSv4' and r.get('score') is not None
for r in ratings):
missing.append(f"{p}:{vuln.get('id')}")
print(f'OK: {p}')
if missing:
print('missing CVSSv4 ratings:', missing, file=sys.stderr)
sys.exit(1)
PY

- name: Unsigned publish tree (hashes + self URL + verify)
run: |
python3 central/csaf-publish \
--docs-dir "${ADV}" \
--out-root "${RUNNER_TEMP}/publish"
python3 central/csaf-verify \
--root "${RUNNER_TEMP}/publish/.well-known/csaf"
python3 - <<'PY'
import hashlib, json, os, pathlib, sys
root = pathlib.Path(os.environ['RUNNER_TEMP']) / 'publish' / '.well-known' / 'csaf'
index = [ln for ln in (root / 'index.txt').read_text().splitlines() if ln]
docs = sorted(p for p in root.glob('*/*/*.json'))
if len(index) != len(docs):
sys.exit(f'ERROR: index has {len(index)} lines, disk has {len(docs)} json files')
for rel in index:
dest = root / rel
if not dest.is_file():
sys.exit(f'ERROR: index path missing: {rel}')
doc = json.loads(dest.read_text())
selfs = [r['url'] for r in doc['document']['references']
if r.get('category') == 'self']
want = 'https://www.wolfssl.com/.well-known/csaf/' + rel
if selfs != [want]:
sys.exit(f'ERROR: self URL {selfs!r} != {want!r}')
for algo in ('sha256', 'sha512'):
side = dest.with_name(dest.name + '.' + algo)
got = hashlib.new(algo, dest.read_bytes()).hexdigest()
if side.read_text().split()[0] != got:
sys.exit(f'ERROR: {algo} mismatch for {rel}')
md = root / 'provider-metadata.json'
for algo in ('sha256', 'sha512'):
side = md.with_name(md.name + '.' + algo)
got = hashlib.new(algo, md.read_bytes()).hexdigest()
if side.read_text().split()[0] != got:
sys.exit(f'ERROR: {algo} mismatch for provider-metadata.json')
print(f'OK: published {len(docs)} unsigned documents')
PY
12 changes: 12 additions & 0 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
selftest:
runs-on: ubuntu-latest
Expand All @@ -34,6 +37,12 @@ jobs:
share/frontends/zephyr_sbom.py \
central/gen-advisory \
central/test_gen_advisory.py \
central/advisory-completeness \
central/test_advisory_completeness.py \
central/csaf-publish \
central/csaf-verify \
central/csaf-keygen \
central/test_csaf_publish.py \
provenance/bomsh_verify.py \
tools/wolfglass-sync \
tests/test_gen_sbom.py \
Expand All @@ -46,6 +55,9 @@ jobs:
- name: Run advisory generator unit tests
run: python -m unittest central/test_gen_advisory.py

- name: Run advisory completeness and publish unit tests
run: python -m unittest central/test_advisory_completeness.py central/test_csaf_publish.py

- name: Run SBOM identity tests
run: python -m unittest tests/test_sbom_identity.py

Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
*.spdx.json
*.spdx

# Generated CSAF/VEX (rebuild in CI / with gen-advisory + csaf-publish)
advisories/out/
advisories/publish/

# Build provenance output
omnibor/
*_raw_logfile*
Expand All @@ -12,8 +16,13 @@ __pycache__/
*.py[cod]
.pytest_cache/
.venv/
.venv-poc/
venv/

# CSAF PoC: throwaway signing key (never commit a secret key) + node deps
advisories/.poc-key/
tools/csaf-validate/node_modules/

# Editor / OS
.DS_Store
*.swp
Loading
Loading