diff --git a/.github/workflows/advisory.yml b/.github/workflows/advisory.yml index c9b21e0..1ba4388 100644 --- a/.github/workflows/advisory.yml +++ b/.github/workflows/advisory.yml @@ -34,10 +34,12 @@ jobs: central/csaf-publish \ central/csaf-verify \ central/csaf-keygen \ + central/release-advisory \ central/test_gen_advisory.py \ central/test_advisory_completeness.py \ central/test_advisory_overlay_draft.py \ - central/test_csaf_publish.py + central/test_csaf_publish.py \ + central/test_release_advisory.py - name: Unit tests run: | @@ -45,7 +47,8 @@ jobs: central/test_gen_advisory.py \ central/test_advisory_completeness.py \ central/test_advisory_overlay_draft.py \ - central/test_csaf_publish.py + central/test_csaf_publish.py \ + central/test_release_advisory.py - name: Completeness gate for every release directory run: | diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 37bf8f6..9131867 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -52,7 +52,9 @@ jobs: central/csaf-publish \ central/csaf-verify \ central/csaf-keygen \ + central/release-advisory \ central/test_csaf_publish.py \ + central/test_release_advisory.py \ provenance/bomsh_verify.py \ tools/wolfssl-compliance-tooling-sync \ tests/test_gen_sbom.py \ @@ -74,7 +76,7 @@ jobs: 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_advisory_overlay_draft.py central/test_csaf_publish.py + run: python -m unittest central/test_advisory_completeness.py central/test_advisory_overlay_draft.py central/test_csaf_publish.py central/test_release_advisory.py - name: Run SBOM identity tests run: python -m unittest tests/test_sbom_identity.py diff --git a/central/README.md b/central/README.md index 52d9858..dfa96b3 100644 --- a/central/README.md +++ b/central/README.md @@ -67,6 +67,29 @@ These tools run for wolfSSL only. Do not vendor them into a product. - `advisory-vex-overlay.schema.json` — the per-CVE overlay schema. - `advisory-vex-overlay.example.json` — an overlay example. +- `release-advisory` — one wolfSSL release, in two runs. The name is not + `wolfssl-compliance-tooling.sh`. That path is the SBOM driver. + + python3 central/release-advisory draft --release 5.9.4 \ + --changelog ../wolfssl/ChangeLog.md + + `draft` writes missing overlay lines and stops. Read every REVIEW line. + `advisory-completeness` does not judge the determination. + + python3 central/release-advisory publish --release 5.9.4 \ + --changelog ../wolfssl/ChangeLog.md \ + --gpg-key <40-hex primary fingerprint> + + `publish` checks completeness, writes the bundle and the per-CVE + documents, and adds them to the provider tree (`csaf-publish --keep`). + Signing is `--gpg-key`. A separate `gpg --armor --detach-sign` does not + update `provider-metadata.json`. The command then prints the copy to the + website and the GitHub release update. + + Only wolfSSL is accepted. One product per run. Other products stay in + the provider tree because `--keep` does not rebuild it from this release + alone. + The advisory tool is already multi-product. It keys the PURL and the CPE per product. The security team makes the advisory from CVE records. No product needs a build step for advisories. diff --git a/central/csaf-publish b/central/csaf-publish index 64c801d..df99f77 100755 --- a/central/csaf-publish +++ b/central/csaf-publish @@ -224,6 +224,82 @@ def _reset_tree(csaf_root): shutil.rmtree(d) +def _read_index_lines(csaf_root): + index = csaf_root / 'index.txt' + if not index.is_file(): + return [] + lines = [] + for ln in index.read_text().splitlines(): + line = ln.strip() + if line and not line.startswith('#'): + lines.append(line) + return lines + + +def _read_changes(csaf_root): + path = csaf_root / 'changes.csv' + if not path.is_file(): + return [] + rows = [] + with path.open(newline='') as fh: + for row in csv.reader(fh): + if len(row) >= 2 and row[0].strip(): + rows.append((row[0].strip(), row[1].strip())) + return rows + + +def _merge_kept(csaf_root, prior_index, prior_changes, new_index, new_changes, + timestamp): + """Keep indexed documents this run did not replace. + + A later product, or a later wolfSSL release, must not delete the files + already in the provider tree. The new index is the old index plus this + run. A path this run writes replaces that one entry. + """ + new_set = set(new_index) + kept = [] + for rel in prior_index: + if rel in new_set: + continue + if (csaf_root / rel).is_file(): + kept.append(rel) + index = kept + list(new_index) + times = {rel: ts for rel, ts in prior_changes} + for rel, ts in new_changes: + times[rel] = ts + rows = [(rel, times.get(rel, timestamp)) for rel in index] + rows.sort(key=lambda r: r[1], reverse=True) + return index, rows + + +def _clear_signature(path): + """Remove a detached signature for a file this unsigned run rewrote. + + --keep does not reset the tree. A previous .asc would otherwise sit + beside the new bytes and no longer match them. + """ + asc = path.with_name(path.name + '.asc') + if asc.is_file(): + asc.unlink() + + +def _drop_unindexed(csaf_root, indexed): + """Remove a TLP document that is not in the index, and its sidecars.""" + for tlp in ('white', 'green', 'amber', 'red'): + tlp_dir = csaf_root / tlp + if not tlp_dir.is_dir(): + continue + for path in tlp_dir.glob('*/*.json'): + rel = path.relative_to(csaf_root).as_posix() + if rel in indexed: + continue + path.unlink() + for suffix in ('.sha256', '.sha512', '.asc'): + side = path.with_name(path.name + suffix) + if side.is_file(): + side.unlink() + + def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -247,6 +323,13 @@ def main(): 'key id and a subkey fingerprint are rejected. ' 'The secret key is not exported. Do not combine ' 'with --key-file.') + p.add_argument('--keep', action='store_true', + help='Add this run to the existing provider tree. ' + 'Documents already listed in index.txt stay. ' + 'A document this run writes replaces the same path. ' + 'A .json file that is not in the index is removed. ' + 'Without this flag the tree is rebuilt from ' + '--docs-dir only.') args = p.parse_args() if args.key_file and args.gpg_key: sys.exit('ERROR: pass only one of --key-file and --gpg-key.') @@ -254,7 +337,18 @@ def main(): docs_dir = pathlib.Path(args.docs_dir) csaf_root = pathlib.Path(args.out_root) / '.well-known' / 'csaf' csaf_root.mkdir(parents=True, exist_ok=True) - _reset_tree(csaf_root) + prior_index = [] + prior_changes = [] + if args.keep: + prior_index = _read_index_lines(csaf_root) + prior_changes = _read_changes(csaf_root) + if not prior_index: + print('WARNING: --keep found no index.txt. This run is the ' + 'whole catalogue. Download the published tree into ' + '--out-root first if that directory should already ' + 'contain it.', file=sys.stderr) + else: + _reset_tree(csaf_root) timestamp = build_timestamp() @@ -312,11 +406,19 @@ def main(): _sha_files(dest) if signer is not None: signer(dest) + else: + _clear_signature(dest) index_lines.append(rel) released = doc['document']['tracking'].get('current_release_date', timestamp) changes_rows.append((rel, released)) + if args.keep: + index_lines, changes_rows = _merge_kept( + csaf_root, prior_index, prior_changes, index_lines, changes_rows, + timestamp) + _drop_unindexed(csaf_root, set(index_lines)) + # index.txt (csaf_root / 'index.txt').write_text('\n'.join(index_lines) + '\n') @@ -334,6 +436,8 @@ def main(): _sha_files(md_path) if signer is not None: signer(md_path) + else: + _clear_signature(md_path) signed = 'signed' if signer is not None else 'UNSIGNED' print(f'Published {len(csaf_docs)} CSAF document(s) [{signed}] to {csaf_root}') diff --git a/central/release-advisory b/central/release-advisory new file mode 100755 index 0000000..6e6812d --- /dev/null +++ b/central/release-advisory @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Run one wolfSSL advisory release, then print the steps that stay manual. + +`draft` writes missing VEX overlay lines from the ChangeLog and stops. +Read every REVIEW line before anything is published. +`advisory-completeness` checks that each CVE has a record and an overlay +entry. It does not judge whether the determination is right. + +`publish` runs only after that review. It checks completeness, writes the +release bundle and the per-CVE documents, and adds those documents to the +provider tree. Signing is `csaf-publish --gpg-key`. A separate +`gpg --armor --detach-sign` does not update provider-metadata.json. + +The command accepts one product. wolfSSH, wolfTPM, and wolfBoot need a +product profile before this command can run them. Releases are staggered, +so one run must leave the other products' files in the provider tree. +""" + +import argparse +import pathlib +import shlex +import shutil +import subprocess +import sys +import tempfile + +_HERE = pathlib.Path(__file__).resolve().parent +_REPO = _HERE.parent +_SUPPORTED_PRODUCT = 'wolfSSL' + + +def _reject_other_product(product): + if product != _SUPPORTED_PRODUCT: + sys.exit( + f'ERROR: release-advisory supports {_SUPPORTED_PRODUCT} only. ' + f'{product} needs a product profile first: the ChangeLog header, ' + 'the bullet shape, the CPE, and the overlay file. ' + 'One product per run. csaf-publish --keep leaves the other ' + 'products in the provider tree.') + + +def _run(argv): + shown = ' '.join(argv) + print(f'+ {shown}') + result = subprocess.run(argv) + if result.returncode != 0: + sys.exit(result.returncode) + + +def _pin_ids(releases_root, release): + pin = pathlib.Path(releases_root) / release / 'cves' + if not pin.is_file(): + sys.exit(f'ERROR: pin file not found: {pin}') + ids = [] + for ln in pin.read_text().splitlines(): + # Same rule as advisory-completeness.load_cve_list: '#' starts a + # comment, including a comment after the id on the same line. + line = ln.split('#', 1)[0].strip() + if line: + ids.append(line) + if not ids: + sys.exit(f'ERROR: pin file is empty: {pin}') + return ids + + +def _copy_pin_records(ids, records_dir, dest): + records = pathlib.Path(records_dir) + dest.mkdir(parents=True, exist_ok=True) + for cve in ids: + src = records / f'{cve}.json' + if not src.is_file(): + sys.exit(f'ERROR: missing record {src}') + shutil.copy(src, dest / src.name) + + +def draft_argv(py, release, changelog, overlay, replace, dry_run): + argv = [py, str(_HERE / 'advisory-overlay-draft'), + '--release', release, + '--changelog', changelog, + '--product', _SUPPORTED_PRODUCT] + if overlay: + argv.extend(['--overlay', overlay]) + if replace: + argv.append('--replace') + if dry_run: + argv.append('--dry-run') + return argv + + +def print_draft_stop(release, changelog, overlay): + parts = [sys.executable, str(_HERE / 'release-advisory'), 'publish', + '--release', release, '--changelog', changelog] + if overlay: + parts.extend(['--overlay', overlay]) + shown = ' '.join(shlex.quote(part) for part in parts) + print() + print('Stop here. Read every REVIEW line.') + print('advisory-completeness does not judge the determination.') + print('When the overlay is right, run:') + print(f' {shown}') + + +def completeness_argv(py, release, changelog, overlay, records_dir, + releases_root): + argv = [ + py, str(_HERE / 'advisory-completeness'), + '--release-dir', str(pathlib.Path(releases_root) / release), + '--changelog', changelog, + '--product', _SUPPORTED_PRODUCT, + '--records-dir', records_dir, + ] + if overlay: + argv.extend(['--overlay', overlay]) + return argv + + +def publish_steps(py, release, overlay, records_dir, releases_root, + out_dir, out_root, gpg_key, base_url, pin_records_dir): + """Command list after the completeness gate. pin_records_dir holds this pin.""" + steps = [] + bundle = f'wolfssl-{release}' + gen = [ + py, str(_HERE / 'gen-advisory'), + '--records-dir', records_dir, + '--releases-root', releases_root, + '--advisory-id', bundle, + '--csaf-out', str(pathlib.Path(out_dir) / f'{bundle}.csaf.json'), + '--cdx-vex-out', str(pathlib.Path(out_dir) / f'{bundle}.cdx.json'), + ] + if overlay: + gen.extend(['--vex-overlay', overlay]) + steps.append(gen) + per_cve = [ + py, str(_HERE / 'gen-advisory'), + '--records-dir', pin_records_dir, + '--out-dir', out_dir, + ] + if overlay: + per_cve.extend(['--vex-overlay', overlay]) + steps.append(per_cve) + publish = [ + py, str(_HERE / 'csaf-publish'), + '--docs-dir', out_dir, + '--out-root', out_root, + '--keep', + ] + if base_url: + publish.extend(['--base-url', base_url]) + if gpg_key: + publish.extend(['--gpg-key', gpg_key]) + steps.append(publish) + verify = [ + py, str(_HERE / 'csaf-verify'), + '--root', str(pathlib.Path(out_root) / '.well-known' / 'csaf'), + ] + if gpg_key: + verify.extend(['--fingerprint', gpg_key]) + steps.append(verify) + return steps + + +def print_publish_manual(release, out_dir, out_root, gpg_key): + tree = pathlib.Path(out_root) / '.well-known' / 'csaf' + print() + print(f'Review of the {release} overlay is a separate step. ' + 'Do not copy a tree you have not read.') + print(f'Copy {tree} onto the website provider directory. ' + 'Add these files to the directory that is already there. ' + 'A full replacement removes the product that shipped earlier.') + print('--keep merges this run with the index.txt already in that tree. ' + 'advisories/publish is gitignored, so a fresh clone has no index. ' + 'Download the live provider directory with csaf-fetch into ' + '--out-root before publish. Copying this index up from an empty ' + 'tree drops every advisory that was only on the website.') + print(f'Update the GitHub release for wolfSSL {release} ' + f'with the bundle in {out_dir}.') + if gpg_key: + print('The signature is already in that tree: provider-metadata.json ' + 'and the .asc files. A separate gpg --armor --detach-sign ' + 'does not update provider-metadata.json.') + else: + print('This tree is unsigned. Rerun release-advisory publish ' + 'with --gpg-key and the full 40-hex primary fingerprint. ' + 'Do not run csaf-publish --gpg-key by itself. Without ' + '--keep it deletes the provider tree. ' + 'A separate gpg --armor --detach-sign does not update ' + 'provider-metadata.json.') + + +def cmd_draft(args): + _reject_other_product(args.product) + _run(draft_argv(sys.executable, args.release, args.changelog, args.overlay, + args.replace, args.dry_run)) + print_draft_stop(args.release, args.changelog, args.overlay) + + +def cmd_publish(args): + _reject_other_product(args.product) + # advisory-completeness skips the pin check when this path is not a + # file. Reject it here so a typo cannot publish. + if not pathlib.Path(args.changelog).is_file(): + sys.exit(f'ERROR: changelog not found: {args.changelog}') + ids = _pin_ids(args.releases_root, args.release) + _run(completeness_argv( + sys.executable, args.release, args.changelog, args.overlay, + args.records_dir, args.releases_root)) + out_dir = pathlib.Path(args.out_dir) + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + with tempfile.TemporaryDirectory() as tmp: + pin_dir = str(pathlib.Path(tmp) / 'records') + _copy_pin_records(ids, args.records_dir, pathlib.Path(pin_dir)) + steps = publish_steps( + sys.executable, args.release, args.overlay, + args.records_dir, args.releases_root, str(out_dir), args.out_root, + args.gpg_key, args.base_url, pin_dir) + for argv in steps: + _run(argv) + print_publish_manual(args.release, out_dir, args.out_root, args.gpg_key) + + +def main(): + p = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest='cmd', required=True) + + common = argparse.ArgumentParser(add_help=False) + common.add_argument('--release', required=True, + help='wolfSSL version, for example 5.9.4.') + common.add_argument('--changelog', required=True, + help='Path to the wolfSSL ChangeLog.') + common.add_argument('--product', default=_SUPPORTED_PRODUCT, + help='Product name. Only wolfSSL is accepted.') + common.add_argument('--overlay', default=None, + help='VEX overlay JSON. The default is ' + 'advisories/vex-overlay.json inside each tool.') + + draft = sub.add_parser( + 'draft', parents=[common], + help='Write missing overlay lines and stop for review.') + draft.add_argument('--replace', action='store_true', + help='Rewrite overlay keys that this release drafts.') + draft.add_argument('--dry-run', action='store_true', + help='Print the draft and write nothing.') + draft.set_defaults(func=cmd_draft) + + publish = sub.add_parser( + 'publish', parents=[common], + help='After review: check, generate, and add documents to the tree.') + publish.add_argument('--records-dir', + default=str(_REPO / 'advisories' / 'records'), + help='Directory of CVE JSON 5.x records.') + publish.add_argument('--releases-root', + default=str(_REPO / 'advisories' / 'releases'), + help='Directory of /cves pin files.') + publish.add_argument('--out-dir', default=None, + help='Generated documents for this release. ' + 'Default: advisories/out/wolfssl-. ' + 'The directory is replaced on each run.') + publish.add_argument('--out-root', + default=str(_REPO / 'advisories' / 'publish'), + help='Provider tree root. Documents are added. ' + 'Other products stay.') + publish.add_argument('--base-url', default=None, + help='Public base URL. The publish tool default ' + 'is used when this is omitted.') + publish.add_argument('--gpg-key', default=None, + help='Full 40-hex primary fingerprint. ' + 'Passed to csaf-publish --gpg-key and ' + 'csaf-verify --fingerprint.') + publish.set_defaults(func=cmd_publish) + + args = p.parse_args() + if args.cmd == 'publish' and not args.out_dir: + args.out_dir = str(_REPO / 'advisories' / 'out' / f'wolfssl-{args.release}') + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/central/test_csaf_publish.py b/central/test_csaf_publish.py index 711eedf..60010f6 100644 --- a/central/test_csaf_publish.py +++ b/central/test_csaf_publish.py @@ -70,7 +70,7 @@ def setUp(self): def tearDown(self): self.tmp.cleanup() - def _publish(self): + def _publish(self, extra=()): # Drive the CLI through the same argv path CI will use. import sys argv = sys.argv @@ -80,6 +80,7 @@ def _publish(self): '--docs-dir', str(self.docs), '--out-root', str(self.out), '--base-url', 'https://www.wolfssl.com/.well-known/csaf', + *extra, ] pub.main() finally: @@ -110,6 +111,39 @@ def test_layout_hashes_and_self_url(self): side = md_path.with_name(md_path.name + '.' + algo) self.assertEqual(side.read_text().split()[0], _sha(md_path, algo)) + def test_unsigned_keep_drops_signatures_of_rewritten_files(self): + import contextlib + import io + csaf_root = self._publish() + doc = csaf_root / 'white' / '2026' / 'cve-2026-5501.json' + kept = csaf_root / 'white' / '2026' / 'cve-1999-0001.json' + kept.write_text('{}\n') + (csaf_root / 'index.txt').write_text( + 'white/2026/cve-2026-5501.json\n' + 'white/2026/cve-1999-0001.json\n') + doc_asc = doc.with_name(doc.name + '.asc') + kept_asc = kept.with_name(kept.name + '.asc') + md_asc = (csaf_root / 'provider-metadata.json').with_name( + 'provider-metadata.json.asc') + for path in (doc_asc, kept_asc, md_asc): + path.write_text('OLD\n') + err = io.StringIO() + with contextlib.redirect_stderr(err): + self._publish(extra=['--keep']) + self.assertFalse(doc_asc.exists()) + self.assertFalse(md_asc.exists()) + self.assertTrue(kept.is_file()) + self.assertEqual(kept_asc.read_text(), 'OLD\n') + self.assertNotIn('WARNING:', err.getvalue()) + + def test_keep_warns_when_the_tree_has_no_index(self): + import contextlib + import io + err = io.StringIO() + with contextlib.redirect_stderr(err): + self._publish(extra=['--keep']) + self.assertIn('no index.txt', err.getvalue()) + def test_rerun_drops_stale_files(self): csaf_root = self._publish() stale = csaf_root / 'white' / '2026' / 'cve-1999-0001.json' @@ -120,6 +154,44 @@ def test_rerun_drops_stale_files(self): json_docs = sorted(p.name for p in (csaf_root / 'white' / '2026').glob('*.json')) self.assertEqual(json_docs, ['cve-2026-5501.json']) + def test_keep_adds_a_document_and_drops_an_unindexed_file(self): + csaf_root = self._publish() + kept = csaf_root / 'white' / '2026' / 'cve-2026-5501.json' + kept_hash = _sha(kept, 'sha256') + docs2 = self.root / 'docs2' + docs2.mkdir() + second = json.loads((self.docs / 'CVE-2026-5501.csaf.json').read_text()) + second['document']['tracking']['id'] = 'CVE-2026-5999' + (docs2 / 'CVE-2026-5999.csaf.json').write_text( + json.dumps(second, indent=2) + '\n') + stale = csaf_root / 'white' / '2026' / 'cve-1999-0001.json' + stale.write_text('{}\n') + import sys + argv = sys.argv + try: + sys.argv = [ + 'csaf-publish', + '--docs-dir', str(docs2), + '--out-root', str(self.out), + '--base-url', 'https://www.wolfssl.com/.well-known/csaf', + '--keep', + ] + pub.main() + finally: + sys.argv = argv + self.assertTrue(kept.is_file()) + self.assertEqual(_sha(kept, 'sha256'), kept_hash) + self.assertTrue((csaf_root / 'white' / '2026' / 'cve-2026-5999.json').is_file()) + self.assertFalse(stale.exists()) + index = (csaf_root / 'index.txt').read_text().splitlines() + self.assertEqual(index, [ + 'white/2026/cve-2026-5501.json', + 'white/2026/cve-2026-5999.json', + ]) + changes = (csaf_root / 'changes.csv').read_text() + self.assertIn('white/2026/cve-2026-5501.json', changes) + self.assertIn('white/2026/cve-2026-5999.json', changes) + def test_source_date_epoch_pins_metadata_timestamp(self): saved = os.environ.get('SOURCE_DATE_EPOCH') os.environ['SOURCE_DATE_EPOCH'] = '1700000000' diff --git a/central/test_release_advisory.py b/central/test_release_advisory.py new file mode 100644 index 0000000..ff80784 --- /dev/null +++ b/central/test_release_advisory.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Unit tests for central/release-advisory. + +Run: + python3 -m unittest central/test_release_advisory.py +""" + +import contextlib +import importlib.util +import io +import pathlib +import shlex +import subprocess +import sys +import tempfile +import textwrap +import unittest +from importlib.machinery import SourceFileLoader + +HERE = pathlib.Path(__file__).resolve().parent +SCRIPT = HERE / 'release-advisory' + +CHANGELOG = textwrap.dedent("""\ + # wolfSSL Release 5.9.4 (Sep 17, 2026) + ## Vulnerabilities + * [High] CVE-2026-11310 + This affects only builds with --enable-opensslextra (OPENSSL_EXTRA). + """) + + +def _load(): + loader = SourceFileLoader('release_advisory', str(SCRIPT)) + spec = importlib.util.spec_from_loader('release_advisory', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +rel = _load() + + +class ArgvTests(unittest.TestCase): + def test_draft_argv_stops_at_the_overlay_tool(self): + argv = rel.draft_argv('python3', '5.9.4', 'ChangeLog.md', None, + False, True) + self.assertEqual(argv[1], str(HERE / 'advisory-overlay-draft')) + self.assertIn('--dry-run', argv) + self.assertNotIn('gen-advisory', ' '.join(argv)) + self.assertNotIn('csaf-publish', ' '.join(argv)) + + def test_publish_steps_sign_inside_csaf_publish_and_keep_the_tree(self): + steps = rel.publish_steps( + 'python3', '5.9.4', 'advisories/vex-overlay.json', + 'advisories/records', 'advisories/releases', + 'advisories/out/wolfssl-5.9.4', 'advisories/publish', + 'ABCDEF0123456789ABCDEF0123456789ABCDEF01', None, + '/tmp/pin-records') + flat = [' '.join(step) for step in steps] + self.assertEqual(len(steps), 4) + self.assertIn('gen-advisory', flat[0]) + self.assertIn('--advisory-id wolfssl-5.9.4', flat[0]) + self.assertIn('--records-dir /tmp/pin-records', flat[1]) + self.assertIn('csaf-publish', flat[2]) + self.assertIn('--keep', flat[2]) + self.assertIn('--gpg-key ABCDEF0123456789ABCDEF0123456789ABCDEF01', + flat[2]) + self.assertNotIn('gpg --armor', flat[2]) + self.assertIn('csaf-verify', flat[3]) + self.assertIn('--fingerprint ABCDEF0123456789ABCDEF0123456789ABCDEF01', + flat[3]) + + def test_completeness_uses_the_release_directory(self): + argv = rel.completeness_argv( + 'python3', '5.9.4', 'ChangeLog.md', None, + 'advisories/records', 'advisories/releases') + self.assertIn('--release-dir', argv) + self.assertIn('advisories/releases/5.9.4', argv) + self.assertIn('--changelog', argv) + + def test_pin_ids_strip_inline_comments(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + release = root / '5.9.4' + release.mkdir() + (release / 'cves').write_text( + 'CVE-2026-5501 # late disclosure\n' + '# whole line\n' + '\n' + 'CVE-2026-11310\n') + self.assertEqual( + rel._pin_ids(root, '5.9.4'), + ['CVE-2026-5501', 'CVE-2026-11310']) + + def test_draft_stop_includes_the_overlay_and_quotes_it(self): + buf = io.StringIO() + overlay = '/tmp/my overlay.json' + with contextlib.redirect_stdout(buf): + rel.print_draft_stop('5.9.4', 'Change Log.md', overlay) + text = buf.getvalue() + self.assertIn('--overlay', text) + self.assertIn(shlex.quote(overlay), text) + self.assertIn(shlex.quote('Change Log.md'), text) + self.assertIn('publish', text) + + def test_unsigned_message_says_to_rerun_publish(self): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rel.print_publish_manual('5.9.4', 'out', 'publish', None) + text = buf.getvalue() + self.assertIn('Rerun release-advisory publish', text) + self.assertIn('--gpg-key', text) + self.assertIn('csaf-publish --gpg-key by itself', text) + self.assertIn('csaf-fetch', text) + self.assertIn('fresh clone has no index', text) + self.assertNotIn('Sign it with csaf-publish', text) + + +class CliTests(unittest.TestCase): + def test_draft_rejects_another_product(self): + result = subprocess.run( + [sys.executable, str(SCRIPT), 'draft', + '--release', '5.9.4', + '--changelog', 'ChangeLog.md', + '--product', 'wolfSSH'], + check=False, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('wolfSSL only', result.stderr) + self.assertIn('product profile', result.stderr) + + def test_draft_prints_review_and_does_not_publish(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + changelog = root / 'ChangeLog.md' + changelog.write_text(CHANGELOG) + overlay = root / 'vex-overlay.json' + overlay.write_text('{}\n') + result = subprocess.run( + [sys.executable, str(SCRIPT), 'draft', + '--release', '5.9.4', + '--changelog', str(changelog), + '--overlay', str(overlay), + '--dry-run'], + check=False, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('REVIEW', result.stdout) + self.assertIn('Stop here', result.stdout) + self.assertIn('does not judge the determination', result.stdout) + self.assertIn('--overlay', result.stdout) + self.assertIn(shlex.quote(str(overlay)), result.stdout) + self.assertEqual(overlay.read_text(), '{}\n') + self.assertFalse((root / 'publish').exists()) + + def test_publish_rejects_another_product_before_it_writes(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + out = root / 'out' + result = subprocess.run( + [sys.executable, str(SCRIPT), 'publish', + '--release', '5.9.4', + '--changelog', 'ChangeLog.md', + '--product', 'wolfTPM', + '--out-dir', str(out), + '--releases-root', str(root / 'releases'), + '--records-dir', str(root / 'records')], + check=False, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('wolfSSL only', result.stderr) + self.assertFalse(out.exists()) + + def test_publish_rejects_a_missing_changelog(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + out = root / 'out' + missing = root / 'ChangeLog.mdx' + result = subprocess.run( + [sys.executable, str(SCRIPT), 'publish', + '--release', '5.9.4', + '--changelog', str(missing), + '--out-dir', str(out), + '--releases-root', str(root / 'releases'), + '--records-dir', str(root / 'records')], + check=False, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f'changelog not found: {missing}', result.stderr) + self.assertFalse(out.exists())