From 9a250a4b4bec1660aef5a70b91d76e46f10f7fd0 Mon Sep 17 00:00:00 2001 From: Christopher Angelo Phillips <32073428+spiffcs@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:16:32 -0500 Subject: [PATCH] fix: update mixed case dependencies in python to be normalized (#4573) --------- Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> --- syft/pkg/cataloger/python/cataloger_test.go | 1 + syft/pkg/cataloger/python/dependency.go | 6 +- syft/pkg/cataloger/python/dependency_test.go | 114 ++++++++++++++++++ .../poetry/case-sensitivity/poetry.lock | 55 +++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 syft/pkg/cataloger/python/test-fixtures/poetry/case-sensitivity/poetry.lock diff --git a/syft/pkg/cataloger/python/cataloger_test.go b/syft/pkg/cataloger/python/cataloger_test.go index ab7e15b06..1da9f59e4 100644 --- a/syft/pkg/cataloger/python/cataloger_test.go +++ b/syft/pkg/cataloger/python/cataloger_test.go @@ -635,6 +635,7 @@ func Test_PackageCataloger_Relationships(t *testing.T) { "jinja2 @ 3.1.4 (.) [dependency-of] fastapi @ 0.111.0 (.)", "jinja2 @ 3.1.4 (.) [dependency-of] starlette @ 0.37.2 (.)", "markdown-it-py @ 3.0.0 (.) [dependency-of] rich @ 13.7.1 (.)", + "markupsafe @ 2.1.5 (.) [dependency-of] jinja2 @ 3.1.4 (.)", // MarkupSafe (mixed case) -> markupsafe "mdurl @ 0.1.2 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)", "orjson @ 3.10.3 (.) [dependency-of] fastapi @ 0.111.0 (.)", "pydantic @ 2.7.1 (.) [dependency-of] fastapi @ 0.111.0 (.)", diff --git a/syft/pkg/cataloger/python/dependency.go b/syft/pkg/cataloger/python/dependency.go index cc70159cb..50b7d6c4a 100644 --- a/syft/pkg/cataloger/python/dependency.go +++ b/syft/pkg/cataloger/python/dependency.go @@ -74,8 +74,10 @@ func isDependencyForExtra(dep pkg.PythonPoetryLockDependencyEntry) bool { } func packageRef(name, extra string) string { - cleanExtra := strings.TrimSpace(extra) - cleanName := strings.TrimSpace(name) + // normalize both package name and extra to ensure case-insensitive matching per Python packaging spec + // https://packaging.python.org/en/latest/specifications/name-normalization/ + cleanName := normalize(strings.TrimSpace(name)) + cleanExtra := normalize(strings.TrimSpace(extra)) if cleanExtra == "" { return cleanName } diff --git a/syft/pkg/cataloger/python/dependency_test.go b/syft/pkg/cataloger/python/dependency_test.go index 1dceda546..094c56cbd 100644 --- a/syft/pkg/cataloger/python/dependency_test.go +++ b/syft/pkg/cataloger/python/dependency_test.go @@ -181,6 +181,30 @@ func Test_poetryLockDependencySpecifier(t *testing.T) { }, }, }, + { + name: "dependency names with mixed case should be normalized", + p: pkg.Package{ + Name: "dj-rest-auth", + Metadata: pkg.PythonPoetryLockEntry{ + Dependencies: []pkg.PythonPoetryLockDependencyEntry{ + { + Name: "Django", // note: capital D + Version: ">=4.2,<6.0", + }, + { + Name: "djangorestframework", + Version: ">=3.13.0", + }, + }, + }, + }, + want: dependency.Specification{ + ProvidesRequires: dependency.ProvidesRequires{ + Provides: []string{"dj-rest-auth"}, + Requires: []string{"django", "djangorestframework"}, // "Django" should be normalized to "django" + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -195,6 +219,38 @@ func Test_poetryLockDependencySpecifier_againstPoetryLock(t *testing.T) { fixture string want []dependency.Specification }{ + { + name: "case-insensitive dependency resolution", + fixture: "test-fixtures/poetry/case-sensitivity/poetry.lock", + want: []dependency.Specification{ + // packages are in the order they appear in the lock file + { + ProvidesRequires: dependency.ProvidesRequires{ + Provides: []string{"django"}, + Requires: []string{"asgiref", "sqlparse"}, + }, + }, + { + ProvidesRequires: dependency.ProvidesRequires{ + Provides: []string{"djangorestframework"}, + Requires: []string{"django"}, + }, + }, + { + // dj-rest-auth depends on Django (capital D) which should resolve to django + ProvidesRequires: dependency.ProvidesRequires{ + Provides: []string{"dj-rest-auth"}, + Requires: []string{"django", "djangorestframework"}, // Django normalized to django + }, + Variants: []dependency.ProvidesRequires{ + { + Provides: []string{"dj-rest-auth[with-social]"}, + Requires: []string{"django-allauth"}, + }, + }, + }, + }, + }, { name: "simple dependencies with extras", fixture: "test-fixtures/poetry/simple-deps/poetry.lock", @@ -276,6 +332,64 @@ func Test_poetryLockDependencySpecifier_againstPoetryLock(t *testing.T) { } } +// Test_packageRef verifies that package references are normalized according to +// the Python Packaging specification for names and extras: +// https://packaging.python.org/en/latest/specifications/name-normalization/ +func Test_packageRef(t *testing.T) { + tests := []struct { + name string + pkg string + extra string + want string + }{ + { + name: "simple package name", + pkg: "requests", + want: "requests", + }, + { + name: "package with extra", + pkg: "requests", + extra: "security", + want: "requests[security]", + }, + { + name: "package name with mixed case", + pkg: "Django", + want: "django", + }, + { + name: "package name with underscores", + pkg: "some_package", + want: "some-package", + }, + { + name: "package name with mixed case and extra", + pkg: "Django", + extra: "argon2", + want: "django[argon2]", + }, + { + name: "extra with mixed case", + pkg: "package", + extra: "Security", + want: "package[security]", + }, + { + name: "both with mixed case and separators", + pkg: "Some_Package", + extra: "Dev_Extra", + want: "some-package[dev-extra]", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := packageRef(tt.pkg, tt.extra) + assert.Equal(t, tt.want, got) + }) + } +} + func Test_extractPackageName(t *testing.T) { tests := []struct { name string diff --git a/syft/pkg/cataloger/python/test-fixtures/poetry/case-sensitivity/poetry.lock b/syft/pkg/cataloger/python/test-fixtures/poetry/case-sensitivity/poetry.lock new file mode 100644 index 000000000..92d489187 --- /dev/null +++ b/syft/pkg/cataloger/python/test-fixtures/poetry/case-sensitivity/poetry.lock @@ -0,0 +1,55 @@ +# This file is automatically @generated by Poetry and should not be edited manually. + +[[package]] +name = "django" +version = "5.2.6" +description = "A high-level Python web framework" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "Django-5.2.6-py3-none-any.whl", hash = "sha256:example1"}, + {file = "django-5.2.6.tar.gz", hash = "sha256:example2"}, +] + +[package.dependencies] +asgiref = ">=3.8.1,<4" +sqlparse = ">=0.3.1" + +[[package]] +name = "djangorestframework" +version = "3.16.1" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:example3"}, + {file = "djangorestframework-3.16.1.tar.gz", hash = "sha256:example4"}, +] + +[package.dependencies] +django = ">=4.2" + +[[package]] +name = "dj-rest-auth" +version = "7.0.1" +description = "Authentication and Registration in Django Rest Framework" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "dj-rest-auth-7.0.1.tar.gz", hash = "sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90"}, +] + +[package.dependencies] +Django = ">=4.2,<6.0" +djangorestframework = ">=3.13.0" + +[package.extras] +with-social = ["django-allauth[socialaccount] (>=64.0.0)"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.10" +content-hash = "example"