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
44 changes: 40 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
45 changes: 44 additions & 1 deletion 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!")
Comment on lines +202 to +203

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have sometimes included CVEs in non-security sections:

Should we not allow those? In those cases, should they have been under Security?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, from a distributor's perspective (though I've never worn that hat ;-)), it would have been clearer to put these in the Security section, since that's what prompted the update.

I'm also not entirely sure these should be marked with .. cve:, as my intention was to reserve that for CVEs we issue ourselves.


if 'gh-issue' not in metadata and 'bpo' not in metadata:
throw("'gh-issue:' or 'bpo:' must be specified in the metadata!")

Expand All @@ -192,7 +226,16 @@ def finish_entry() -> None:
throw(f'Blurb metadata sets {name!r} twice!')
metadata[name] = value
continue
if line.startswith('#') or not line:
if line.startswith('#'):
# Catch a CVE that was filled in but left commented out.
_, found, value = line[1:].strip().partition('.. cve:')
if found and value.strip():
throw(
f'CVE given but the line is still commented out: {line!r}\n'
"Remove the leading '#' to use it."
)
continue
if not line:
continue
in_metadata = False

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
10 changes: 8 additions & 2 deletions src/blurb/_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,20 @@ 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'):
Comment thread
StanFromIreland marked this conversation as resolved.
prefix = f'{cve}, {prefix}' 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/commented-cve.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.. gh-issue: 135661
.. section: Security
#.. cve: CVE-2025-12345

This is an invalid blurb. The CVE was filled in but left commented out.
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.
53 changes: 53 additions & 0 deletions tests/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import blurb._add
from blurb._add import (
_blurb_template_text,
_extract_cve,
_extract_issue_number,
_extract_section_name,
)
Expand Down Expand Up @@ -183,3 +184,55 @@ 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')
Loading
Loading