cros_extract_deps: emit a CPython Git identifier

For dev-lang/python, add a Git identifier with the release tag commit
so AutoVM matches PSF advisories.

BUG=b/536013518
TEST=presubmit
RELEASE_NOTE=None

Change-Id: I624143be162073766980ab35747f0d6fc8b8c1cb
diff --git a/scripts/cros_extract_deps.py b/scripts/cros_extract_deps.py
index f16cce7..bf68b68 100644
--- a/scripts/cros_extract_deps.py
+++ b/scripts/cros_extract_deps.py
@@ -124,6 +124,20 @@
 )
 _PORTAGE_SUFFIX_RE = re.compile(r"_(alpha|beta|pre|rc|p)(\d*)")
 
+# Upstream CPython repository, as used by the OSV PSF advisories.
+_CPYTHON_REPO = "https://github.com/python/cpython"
+# Commit of each CPython release tag (refs/tags/v<version>^{}). A Git
+# identifier with this commit lets AutoVM match PSF advisories in OSV.
+# Add the new version here when dev-lang/python is uprevved.
+CPYTHON_TAG_COMMITS = {
+    "3.6.15": "b74b1f36993a4e1700869133f3be13dd60ef4e40",
+    "3.8.19": "469ede4dbc3ef4146803424a9a3a1e16391f5013",
+    "3.8.20": "39b2f82717a69dde7212bc39b673b0f55c99e6a3",
+    "3.11.15": "2340a037f7450e70fccfe411e6531afb4d57a312",
+    "3.13.13": "01104ce1beb3135c2e0c01ec835b994c1f55a1c0",
+}
+_CPYTHON_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)(?:_p\d*)?(?:-r\d+)?")
+
 
 # PyPI packages installed from outside the dev-python category.
 _PYPI_PACKAGES_OUTSIDE_DEV_PYTHON = frozenset(("app-admin/awscli",))
@@ -171,11 +185,27 @@
     return "PyPI:%s:%s" % (name, pep440_version)
 
 
+def _GetCPythonIdentifier(version):
+    """Build a Git identifier for CPython, or return None if unknown."""
+    match = _CPYTHON_VERSION_RE.fullmatch(version)
+    commit = CPYTHON_TAG_COMMITS.get(match.group(1)) if match else None
+    if not commit:
+        logging.warning(
+            "No CPython tag commit for dev-lang/python-%s; "
+            "add it to CPYTHON_TAG_COMMITS",
+            version,
+        )
+        return None
+    return "Git:%s:%s" % (_CPYTHON_REPO, commit)
+
+
 def GetCPEFromCPV(category, package, version):
     """Look up the CPE for a specified Portage package.
 
     Besides CPEs, this also returns "PyPI:<name>:<version>" identifiers
     for PyPI packages (see _IsPyPIPackage) that have a pypi remote-id.
+    For dev-lang/python it also returns a "Git:<repo>:<commit>"
+    identifier.
 
     Args:
         category: The Portage package's category, e.g. "net-misc"
@@ -228,6 +258,10 @@
     # This is livable so long as you do some fuzzy version number
     # comparison in your vulnerability monitoring, between what-we-have
     # and what-the-advisory-says-is-affected.
+    if category == "dev-lang" and package == "python":
+        identifier = _GetCPythonIdentifier(version)
+        if identifier:
+            cpes.append(identifier)
     return cpes
 
 
diff --git a/scripts/cros_extract_deps_unittest.py b/scripts/cros_extract_deps_unittest.py
index f0282c2..4b117c8 100644
--- a/scripts/cros_extract_deps_unittest.py
+++ b/scripts/cros_extract_deps_unittest.py
@@ -187,6 +187,31 @@
             [], cros_extract_deps.GetCPEFromCPV("app-misc", "foo", "1.0")
         )
 
+    def testGetCPEFromCPVCPython(self):
+        """Check that dev-lang/python gets a Git commit identifier."""
+        stdout = "Remote-ID:   cpe:/a:python:python ID: cpe\n"
+        self.rc.AddCmdResult(
+            ["equery", "m", "-U", "dev-lang/python"], stdout=stdout
+        )
+        self.assertEqual(
+            [
+                "cpe:/a:python:python:3.13.13p1",
+                "Git:https://github.com/python/cpython:"
+                "01104ce1beb3135c2e0c01ec835b994c1f55a1c0",
+            ],
+            cros_extract_deps.GetCPEFromCPV("dev-lang", "python", "3.13.13_p1"),
+        )
+
+    def testGetCPEFromCPVCPythonUnknownVersion(self):
+        """Check that an unknown CPython version emits no Git identifier."""
+        stdout = "Remote-ID:   cpe:/a:python:python ID: cpe\n"
+        self.rc.AddCmdResult(
+            ["equery", "m", "-U", "dev-lang/python"], stdout=stdout
+        )
+        self.assertEqual(
+            ["cpe:/a:python:python:1.2.3"],
+            cros_extract_deps.GetCPEFromCPV("dev-lang", "python", "1.2.3"),
+        )
 
 
 class PortageToPep440Test(cros_test_lib.TestCase):