Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 #<n>: " or "- gh-issue<n>: " or that sort of stuff.
###########################################################################
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 55 additions & 4 deletions src/blurb/_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.:
Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -204,10 +240,25 @@ 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()
print(f'Error: {failure}')
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 ''
34 changes: 34 additions & 0 deletions src/blurb/_blurb_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!")

Expand Down
10 changes: 9 additions & 1 deletion src/blurb/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
9 changes: 7 additions & 2 deletions src/blurb/_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=' ')
Expand Down
5 changes: 5 additions & 0 deletions src/blurb/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 #<n>: " or "- gh-issue-<n>: " or that sort of stuff.
###########################################################################
Expand Down
5 changes: 5 additions & 0 deletions tests/fail/cve-not-security.rst
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions tests/fail/invalid-cve.rst
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions tests/pass/cve.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.. cve: cve-2025-12345
.. date: 2026-10-14
.. gh-issue: 135661
.. nonce: abc123
.. section: Security

Fix a thing.
7 changes: 7 additions & 0 deletions tests/pass/cve.rst.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.. cve: CVE-2025-12345
.. date: 2026-10-14
.. gh-issue: 135661
.. nonce: abc123
.. section: Security

Fix a thing.
72 changes: 72 additions & 0 deletions tests/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Loading
Loading