diff --git a/README.md b/README.md index 31c64d8..21ea2d0 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ The template for the `blurb add` message looks like this: #.. section: Tools/Demos #.. section: C API + # If this is a Security entry, you may also add the CVE identifier + # for the vulnerability. + # + #.. cve: + # Write your Misc/NEWS entry below. It should be a simple ReST paragraph. # Don't start with "- Issue #: " or "- gh-issue: " or that sort of stuff. ########################################################################### @@ -120,6 +125,16 @@ Here's how you interact with the file: $ blurb add -s library ``` +* If the entry is for the `Security` section, you may optionally + uncomment the `#.. cve:` line and add the CVE identifier for the + vulnerability, e.g. `.. cve: CVE-2026-12345`. + The CVE can also be specified via the ``-c`` / ``--cve`` option, + which also selects the `Security` section: + + ```shell + $ blurb add -i 12345 -c CVE-2025-12345 + ``` + * Finally, go to the end of the file, and enter your `NEWS` entry. This should be a single paragraph of English text using simple reST markup. diff --git a/src/blurb/_add.py b/src/blurb/_add.py index a09d19d..6286d26 100644 --- a/src/blurb/_add.py +++ b/src/blurb/_add.py @@ -8,7 +8,7 @@ import sys import tempfile -from blurb._blurb_file import BlurbError, Blurbs +from blurb._blurb_file import BlurbError, Blurbs, cve_section, normalize_cve from blurb._cli import error, prompt from blurb._git import flush_git_add_files, git_add_files from blurb._template import sections, template @@ -23,7 +23,12 @@ FALLBACK_EDITORS = ('/etc/alternatives/editor', 'nano') -def add(*, issue: str | None = None, section: str | None = None): +def add( + *, + issue: str | None = None, + section: str | None = None, + cve: str | None = None, +): """Add a blurb (a `Misc/NEWS.d/next` entry) to the current CPython repo. Use `-i`/`--issue` to specify a GitHub issue number or link, e.g.: @@ -42,13 +47,17 @@ def add(*, issue: str | None = None, section: str | None = None): spaces in names can be substituted for underscores: {sections} + + Use `-c`/`--cve` to specify the CVE identifier for a Security entry, e.g.: + + blurb add -s Security -c CVE-2025-12345 """ # fmt: skip handle, tmp_path = tempfile.mkstemp('.rst') os.close(handle) atexit.register(lambda: os.unlink(tmp_path)) - text = _blurb_template_text(issue=issue, section=section) + text = _blurb_template_text(issue=issue, section=section, cve=cve) with open(tmp_path, 'w', encoding='utf-8') as file: file.write(text) @@ -108,9 +117,19 @@ def _find_editor() -> str: error('Could not find an editor! Set the EDITOR environment variable.') -def _blurb_template_text(*, issue: str | None, section: str | None) -> str: +def _blurb_template_text( + *, + issue: str | None, + section: str | None, + cve: str | None = None, +) -> str: issue_number = _extract_issue_number(issue) section_name = _extract_section_name(section) + cve_id = _extract_cve(cve) + if cve_id is not None and section_name is None: + section_name = cve_section + if cve_id is not None and section_name != cve_section: + raise SystemExit(f'A CVE may only be given for the {cve_section!r} section') text = template @@ -133,9 +152,26 @@ def _blurb_template_text(*, issue: str | None, section: str | None) -> str: pattern = f'.. section: {section_name}' text = text.replace(f'#{pattern}', pattern) + # Uncomment and fill in the CVE if needed. + if cve_id is not None: + cve_line = '#.. cve:' + without_cve = f'\n{cve_line}\n' + if without_cve not in text: + raise SystemExit("Can't find cve line in the template!") + text = text.replace(without_cve, f'\n.. cve: {cve_id}\n') + return text +def _extract_cve(cve: str | None, /) -> str | None: + if cve is None: + return None + cve_id = normalize_cve(cve) + if cve_id is None: + raise SystemExit(f'Invalid CVE identifier: {cve}') + return cve_id + + def _extract_issue_number(issue: str | None, /) -> int | None: if issue is None: return None @@ -204,6 +240,8 @@ def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | Non assert len(blurb) # if parse_blurb succeeds, we should always have a body if len(blurb) > 1: failure = "Too many entries! Don't specify '..' on a line by itself." + else: + failure = _check_commented_cve(tmp_path) if failure: print() @@ -211,3 +249,16 @@ def _add_blurb_from_template(args: Sequence[str], tmp_path: str) -> Blurbs | Non print() return None return blurb + + +def _check_commented_cve(tmp_path: str) -> str: + """Return an error message if a CVE was filled in but left commented out.""" + with open(tmp_path, encoding='utf-8') as file: + for line in file: + line = line.strip() + if line.startswith('#.. cve:') and line.removeprefix('#.. cve:').strip(): + return ( + f'A CVE was given but the line is still commented out: {line!r}\n' + "Remove the leading '#' to use it." + ) + return '' diff --git a/src/blurb/_blurb_file.py b/src/blurb/_blurb_file.py index f6c90c8..7914a96 100644 --- a/src/blurb/_blurb_file.py +++ b/src/blurb/_blurb_file.py @@ -58,6 +58,13 @@ ----------------------------------------------------------------------------- +Optional metadata: + + * "cve" names the CVE identifier (e.g. "CVE-2026-12345") addressed + by the entry. It may only be used in the "Security" section. + +----------------------------------------------------------------------------- + The format of a "next" file is exactly the same, except that we're storing four pieces of metadata in the filename instead of in the metadata section. Those four pieces of metadata are: section, gh-issue, date, and nonce. @@ -88,12 +95,28 @@ root = None # Set by chdir_to_repo_root() lowest_possible_gh_issue_number = 32426 +cve_section = 'Security' + +_cve_re = re.compile(r'(?:CVE-)?(\d{4})-(\d{4,})', re.IGNORECASE | re.ASCII) class BlurbError(RuntimeError): pass +def normalize_cve(value: str, /) -> str | None: + """Return a CVE identifier in canonical "CVE-YYYY-NNNN" form. + + Accepts a case-insensitive "CVE-" prefix, or none at all. + Returns None if the value isn't a valid CVE identifier. + """ + match = _cve_re.fullmatch(value.strip()) + if match is None: + return None + year, number = match.groups() + return f'CVE-{year}-{number}' + + class Blurbs(list): def parse( self, @@ -168,6 +191,17 @@ def finish_entry() -> None: f'Invalid section {value!r}! You must use one of the predefined sections.' ) + if key == 'cve': + cve = normalize_cve(value) + if cve is None: + throw( + f'Invalid CVE identifier: {value!r} (expected CVE-YYYY-NNNN)' + ) + metadata[key] = cve + + if 'cve' in metadata and metadata.get('section') != cve_section: + throw(f"'cve:' may only be used in the {cve_section!r} section!") + if 'gh-issue' not in metadata and 'bpo' not in metadata: throw("'gh-issue:' or 'bpo:' must be specified in the metadata!") diff --git a/src/blurb/_cli.py b/src/blurb/_cli.py index fa21b5d..9d6c6b4 100644 --- a/src/blurb/_cli.py +++ b/src/blurb/_cli.py @@ -70,7 +70,15 @@ def add_subcommand(name: str, doc: str) -> argparse.ArgumentParser: parser_add.add_argument( '-s', '--section', metavar='SECTION', help='section name (case-insensitive)' ) - parser_add.set_defaults(func=lambda ns: add(issue=ns.issue, section=ns.section)) + parser_add.add_argument( + '-c', + '--cve', + metavar='CVE', + help='CVE identifier, e.g. `CVE-2026-12345` (Security section only)', + ) + parser_add.set_defaults( + func=lambda ns: add(issue=ns.issue, section=ns.section, cve=ns.cve) + ) parser_export = add_subcommand('export', export.__doc__) parser_export.set_defaults(func=lambda ns: export()) diff --git a/src/blurb/_merge.py b/src/blurb/_merge.py index 1d7f308..d3ece8c 100644 --- a/src/blurb/_merge.py +++ b/src/blurb/_merge.py @@ -95,14 +95,19 @@ def prnt(msg: str = '', /): prnt(section) prnt('-' * len(section)) prnt() + prefix = '' if metadata.get('gh-issue'): issue_number = metadata['gh-issue'] if int(issue_number): - body = f'gh-{issue_number}: {body}' + prefix = f'gh-{issue_number}' elif metadata.get('bpo'): issue_number = metadata['bpo'] if int(issue_number): - body = f'bpo-{issue_number}: {body}' + prefix = f'bpo-{issue_number}' + if cve := metadata.get('cve'): + prefix = f'{prefix}, {cve}' if prefix else cve + if prefix: + body = f'{prefix}: {body}' body = f'- {body}' text = textwrap_body(body, subsequent_indent=' ') diff --git a/src/blurb/_template.py b/src/blurb/_template.py index b068c9e..2a4b936 100644 --- a/src/blurb/_template.py +++ b/src/blurb/_template.py @@ -28,6 +28,11 @@ #.. section: Tools/Demos #.. section: C API +# If this is a Security entry, you may also add the CVE identifier +# for the vulnerability. +# +#.. cve: + # Write your Misc/NEWS.d entry below. It should be a simple ReST paragraph. # Don't start with "- Issue #: " or "- gh-issue-: " or that sort of stuff. ########################################################################### diff --git a/tests/fail/cve-not-security.rst b/tests/fail/cve-not-security.rst new file mode 100644 index 0000000..85c299b --- /dev/null +++ b/tests/fail/cve-not-security.rst @@ -0,0 +1,5 @@ +.. cve: CVE-2025-12345 +.. gh-issue: 135661 +.. section: Library + +This is an invalid blurb. A CVE may only be given in the Security section. diff --git a/tests/fail/invalid-cve.rst b/tests/fail/invalid-cve.rst new file mode 100644 index 0000000..a6679af --- /dev/null +++ b/tests/fail/invalid-cve.rst @@ -0,0 +1,5 @@ +.. cve: CVE-25-1 +.. gh-issue: 135661 +.. section: Security + +This is an invalid blurb. "CVE-25-1" is not a valid CVE identifier. diff --git a/tests/pass/cve.rst b/tests/pass/cve.rst new file mode 100644 index 0000000..ea09024 --- /dev/null +++ b/tests/pass/cve.rst @@ -0,0 +1,7 @@ +.. cve: cve-2025-12345 +.. date: 2026-10-14 +.. gh-issue: 135661 +.. nonce: abc123 +.. section: Security + +Fix a thing. diff --git a/tests/pass/cve.rst.res b/tests/pass/cve.rst.res new file mode 100644 index 0000000..4884aec --- /dev/null +++ b/tests/pass/cve.rst.res @@ -0,0 +1,7 @@ +.. cve: CVE-2025-12345 +.. date: 2026-10-14 +.. gh-issue: 135661 +.. nonce: abc123 +.. section: Security + +Fix a thing. diff --git a/tests/test_add.py b/tests/test_add.py index 23eb404..fd948d1 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -5,6 +5,8 @@ import blurb._add from blurb._add import ( _blurb_template_text, + _check_commented_cve, + _extract_cve, _extract_issue_number, _extract_section_name, ) @@ -183,3 +185,73 @@ def test_invalid_section_name(section): with pytest.raises(SystemExit, match=error_message): _blurb_template_text(issue=None, section=section) + + +def test_no_cve(): + assert _extract_cve(None) is None + res = _blurb_template_text(issue=None, section=None) + lines = frozenset(res.splitlines()) + assert '#.. cve:' in lines + assert '.. section: Security' not in lines + + +@pytest.mark.parametrize( + ('cve', 'section'), + [ + ('CVE-2025-12345', None), + ('cve-2025-12345', 'Security'), + (' 2025-12345 ', 'security'), + ], +) +def test_valid_cve(cve, section): + assert _extract_cve(cve) == 'CVE-2025-12345' + + res = _blurb_template_text(issue=None, section=section, cve=cve) + lines = frozenset(res.splitlines()) + assert '#.. cve:' not in lines + assert '.. cve: CVE-2025-12345' in lines + # Giving a CVE selects the Security section. + assert '.. section: Security' in lines + assert '#.. section: Security' not in lines + assert '#.. section: Library' in lines + + +@pytest.mark.parametrize('cve', ['', 'abc', 'CVE-25-1', 'CVE-2025', 'CVE-2025-1']) +def test_invalid_cve(cve): + error_message = re.escape(f'Invalid CVE identifier: {cve}') + with pytest.raises(SystemExit, match=error_message): + _blurb_template_text(issue=None, section=None, cve=cve) + + +@pytest.mark.parametrize('section', [s for s in SECTIONS if s != 'Security']) +def test_cve_wrong_section(section): + error_message = re.escape("A CVE may only be given for the 'Security' section") + with pytest.raises(SystemExit, match=error_message): + _blurb_template_text(issue=None, section=section, cve='CVE-2025-12345') + + +def test_malformed_cve_line(monkeypatch): + template = blurb_template.replace('#.. cve:', '#.. cve: ') + error_message = re.escape("Can't find cve line in the template!") + with monkeypatch.context() as cm: + cm.setattr(blurb._add, 'template', template) + with pytest.raises(SystemExit, match=error_message): + _blurb_template_text(issue=None, section=None, cve='CVE-2025-12345') + + +@pytest.mark.parametrize( + ('line', 'expected'), + [ + ('#.. cve:', ''), + ('#.. cve: ', ''), + ('.. cve: CVE-2025-12345', ''), + ('#.. cve: CVE-2025-12345', 'still commented out'), + ('#.. cve:CVE-2025-12345', 'still commented out'), + ], +) +def test_check_commented_cve(line, expected, tmp_path): + path = tmp_path / 'entry.rst' + path.write_text(f'.. gh-issue: 123456\n.. section: Security\n{line}\n\nBody.\n') + result = _check_commented_cve(str(path)) + assert expected in result + assert bool(result) == bool(expected) diff --git a/tests/test_blurb_file.py b/tests/test_blurb_file.py index fc52699..ad509fb 100644 --- a/tests/test_blurb_file.py +++ b/tests/test_blurb_file.py @@ -2,7 +2,7 @@ import time_machine import blurb._blurb_file -from blurb._blurb_file import BlurbError, Blurbs, sortable_datetime +from blurb._blurb_file import BlurbError, Blurbs, normalize_cve, sortable_datetime @pytest.mark.parametrize( @@ -156,6 +156,18 @@ def test_parse_no_changes(): '.. section: IDLE\nHello world!', r"'gh-issue:' or 'bpo:' must be specified in the metadata!", ), + ( + '.. gh-issue: 123456\n.. section: Security\n.. cve: 12345\nHello world!', + r"Invalid CVE identifier: '12345' \(expected CVE-YYYY-NNNN\)", + ), + ( + '.. gh-issue: 123456\n.. section: Security\n.. cve:\nHello world!', + r"Invalid CVE identifier: '' \(expected CVE-YYYY-NNNN\)", + ), + ( + '.. gh-issue: 123456\n.. section: Library\n.. cve: CVE-2025-1234\nHello world!', + r"'cve:' may only be used in the 'Security' section!", + ), ), ) def test_parse_no_body(contents, expected_error): @@ -170,3 +182,55 @@ def test_parse_no_body(contents, expected_error): @time_machine.travel('2025-01-07 16:28:41') def test_sortable_datetime(): assert sortable_datetime() == '2025-01-07-16-28-41' + + +@pytest.mark.parametrize( + 'value, expected', + ( + ('CVE-2025-1234', 'CVE-2025-1234'), + ('cve-2025-1234', 'CVE-2025-1234'), + (' CVE-2025-123456 ', 'CVE-2025-123456'), + ('2025-1234', 'CVE-2025-1234'), + ('', None), + ('CVE-2025', None), + ('CVE-25-1234', None), + ('CVE-2025-123', None), + ('CVE-2025-1234x', None), + ('CVE 2025 1234', None), + ), +) +def test_normalize_cve(value, expected): + assert normalize_cve(value) == expected + + +@pytest.mark.parametrize('cve', ('CVE-2025-1234', 'cve-2025-1234', '2025-1234')) +def test_parse_cve(cve): + # Arrange + contents = f'.. gh-issue: 123456\n.. section: Security\n.. cve: {cve}\nHello world!' + blurbs = Blurbs() + + # Act + blurbs.parse(contents) + + # Assert + metadata, body = blurbs[0] + assert metadata['cve'] == 'CVE-2025-1234' + assert '.. cve: CVE-2025-1234\n' in str(blurbs) + + +def test_save_next_keeps_cve(fs, monkeypatch): + # Arrange + monkeypatch.setattr(blurb._blurb_file, 'root', '/root') + blurbs = Blurbs() + blurbs.parse( + '.. gh-issue: 123456\n.. section: Security\n.. cve: CVE-2025-1234\nHello world!' + ) + + # Act + path = blurbs.save_next() + + # Assert + assert '/Misc/NEWS.d/next/Security/' in path + reloaded = Blurbs() + reloaded.load_next(path) + assert reloaded[0][0]['cve'] == 'CVE-2025-1234' diff --git a/tests/test_cli.py b/tests/test_cli.py index fa5a16a..c42a2bc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,6 +43,13 @@ def test_add_bare(self): args = build_parser().parse_args(['add']) assert args.issue is None assert args.section is None + assert args.cve is None + + def test_add_cve(self): + args = build_parser().parse_args(['add', '-c', 'CVE-2025-12345']) + assert args.cve == 'CVE-2025-12345' + args = build_parser().parse_args(['add', '--cve', 'CVE-2025-12345']) + assert args.cve == 'CVE-2025-12345' def test_add_short_options(self): args = build_parser().parse_args(['add', '-i', '12345', '-s', 'Library']) @@ -143,14 +150,14 @@ def test_no_arguments_runs_add(self, monkeypatch): # 'blurb' with no arguments is equivalent to 'blurb add' calls = [] - def fake_add(*, issue, section): + def fake_add(*, issue, section, cve): """Add a blurb.""" - calls.append((issue, section)) + calls.append((issue, section, cve)) monkeypatch.setattr('blurb._add.add', fake_add) monkeypatch.setattr('blurb._cli.chdir_to_repo_root', lambda: '/') main([]) - assert calls == [(None, None)] + assert calls == [(None, None, None)] def test_subcommand_gets_repo_root(self, monkeypatch): chdir_calls = [] diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000..85389c8 --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from blurb._merge import write_news + + +def test_write_news_renders_cve(fs): + # Arrange + base = 'Misc/NEWS.d/next' + fs.create_file( + f'{base}/Security/2026-10-14-00-00-00.gh-issue-135661.abc123.rst', + contents='.. cve: CVE-2025-6069\n\nFix a security issue.\n', + ) + fs.create_file( + f'{base}/Security/2026-10-14-00-00-01.gh-issue-135660.abc124.rst', + contents='Fix another security issue without a CVE.\n', + ) + fs.create_file( + f'{base}/Library/2026-10-14-00-00-01.gh-issue-135662.abc125.rst', + contents='Fix a library issue.\n', + ) + + # Act + write_news('NEWS', versions=['next']) + + # Assert + text = Path('NEWS').read_text(encoding='utf-8') + assert '- gh-135661, CVE-2025-6069: Fix a security issue.\n' in text + assert '- gh-135660: Fix another security issue without a CVE.\n' in text + assert '- gh-135662: Fix a library issue.\n' in text