From 11a0b5a397eca6beb2c8131f57e6a5803af0c422 Mon Sep 17 00:00:00 2001 From: Lalit Shrotriya Date: Wed, 24 Jun 2026 07:03:11 +0000 Subject: [PATCH] feat(supply_chain): scan tool-specific pyproject.toml dependency tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poetry, PDM, Hatch, and uv each declare dependencies in their own [tool.*] tables. Before this change those tables were silently skipped, producing false negatives for projects that use any of these build tools — the most common case in modern Python projects. Add parsing for: - [tool.poetry.dependencies] / [tool.poetry.dev-dependencies] - [tool.poetry.group..dependencies] (new-style Poetry groups) - [tool.pdm.dev-dependencies] - [tool.hatch.envs..dependencies] - [tool.uv.dev-dependencies] Poetry's special "python" key is excluded (it constrains the interpreter, not a package). All other tool config sections (e.g. [tool.black], [tool.mypy]) are unaffected. Closes #171 Signed-off-by: Lalit Shrotriya --- .../analyzers/static_patterns_supply_chain.py | 67 ++++++++++++++++- tests/unit/test_patterns_new.py | 74 +++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 7d3ba27..bea5e7f 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -441,10 +441,18 @@ def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | N def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None, int]]: """Extract (package_name, version_or_None, line_number) from pyproject.toml. - Only PEP 621 ``[project]`` ``dependencies`` / ``optional-dependencies`` and - PEP 735 ``[dependency-groups]`` hold real packages. Standard metadata keys - (``requires-python``, ``name``, ``version``, ...) are not dependencies and - must not be looked up as packages. + Reads all standard and tool-specific dependency tables: + + * PEP 621 ``[project].dependencies`` / ``[project.optional-dependencies]`` + * PEP 735 ``[dependency-groups]`` + * ``[tool.poetry.dependencies]``, ``[tool.poetry.dev-dependencies]``, + ``[tool.poetry.group..dependencies]`` + * ``[tool.pdm.dev-dependencies]`` + * ``[tool.hatch.envs..dependencies]`` + * ``[tool.uv.dev-dependencies]`` + + Standard metadata keys (``requires-python``, ``name``, ``version``, …) and + Poetry's special ``python`` key are not packages and are never yielded. """ try: data = tomllib.loads(content) @@ -468,6 +476,57 @@ def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None if isinstance(group, list): specs.extend(d for d in group if isinstance(d, str)) + tool = data.get("tool") + if isinstance(tool, dict): + # Poetry: keys are package names, values are version strings or config dicts. + poetry = tool.get("poetry") + if isinstance(poetry, dict): + for table_name in ("dependencies", "dev-dependencies"): + table = poetry.get(table_name) + if isinstance(table, dict): + specs.extend( + pkg for pkg in table if isinstance(pkg, str) and pkg != "python" + ) + # [tool.poetry.group..dependencies] + poetry_groups = poetry.get("group") + if isinstance(poetry_groups, dict): + for group_data in poetry_groups.values(): + if isinstance(group_data, dict): + group_deps = group_data.get("dependencies") + if isinstance(group_deps, dict): + specs.extend( + pkg + for pkg in group_deps + if isinstance(pkg, str) and pkg != "python" + ) + + # PDM: [tool.pdm.dev-dependencies] is a dict of lists of PEP 508 strings. + pdm = tool.get("pdm") + if isinstance(pdm, dict): + pdm_dev = pdm.get("dev-dependencies") + if isinstance(pdm_dev, dict): + for group in pdm_dev.values(): + if isinstance(group, list): + specs.extend(d for d in group if isinstance(d, str)) + + # Hatch: [tool.hatch.envs..dependencies] is a list of PEP 508 strings. + hatch = tool.get("hatch") + if isinstance(hatch, dict): + hatch_envs = hatch.get("envs") + if isinstance(hatch_envs, dict): + for env in hatch_envs.values(): + if isinstance(env, dict): + env_deps = env.get("dependencies") + if isinstance(env_deps, list): + specs.extend(d for d in env_deps if isinstance(d, str)) + + # uv: [tool.uv.dev-dependencies] is a list of PEP 508 strings. + uv = tool.get("uv") + if isinstance(uv, dict): + uv_dev = uv.get("dev-dependencies") + if isinstance(uv_dev, list): + specs.extend(d for d in uv_dev if isinstance(d, str)) + results: list[tuple[str, str | None, int]] = [] for spec in specs: m = re.match(r"^([a-zA-Z][a-zA-Z0-9._-]*)(?:\[.*?\])?\s*(?:([=<>!~]=?)\s*([\d.*]+))?", spec) diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 917a8d0..52aac08 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -938,6 +938,80 @@ def test_pyproject_skips_non_pep508_and_include_group_entries(self) -> None: names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content)) assert names == ["pytest", "ruff"] + def test_pyproject_poetry_deps_extracted(self) -> None: + """[tool.poetry.dependencies] and [tool.poetry.dev-dependencies] are scanned.""" + content = ( + '[tool.poetry]\nname = "mypkg"\nversion = "1.0.0"\n' + "[tool.poetry.dependencies]\n" + 'python = "^3.11"\n' + 'requests = "^2.28"\n' + 'pycrypto = "*"\n' + "[tool.poetry.dev-dependencies]\n" + 'pytest = "^7"\n' + ) + names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content)) + # python is the special marker key, not a package + assert "python" not in names + assert "requests" in names + assert "pycrypto" in names + assert "pytest" in names + + def test_pyproject_poetry_groups_extracted(self) -> None: + """[tool.poetry.group..dependencies] (new-style groups) are scanned.""" + content = ( + '[tool.poetry]\nname = "mypkg"\n' + "[tool.poetry.group.dev.dependencies]\n" + 'black = "^23"\n' + "[tool.poetry.group.docs.dependencies]\n" + 'sphinx = ">=5"\n' + ) + names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content)) + assert names == ["black", "sphinx"] + + def test_pyproject_pdm_dev_deps_extracted(self) -> None: + """[tool.pdm.dev-dependencies] groups of PEP 508 strings are scanned.""" + content = ( + '[project]\nname = "mypkg"\ndependencies = ["httpx"]\n' + "[tool.pdm.dev-dependencies]\n" + 'test = ["pytest>=7", "coverage"]\n' + 'lint = ["ruff"]\n' + ) + names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content)) + assert names == ["coverage", "httpx", "pytest", "ruff"] + + def test_pyproject_hatch_env_deps_extracted(self) -> None: + """[tool.hatch.envs..dependencies] lists are scanned.""" + content = ( + '[project]\nname = "mypkg"\n' + "[tool.hatch.envs.default]\n" + 'dependencies = ["pytest", "pytest-cov"]\n' + "[tool.hatch.envs.lint]\n" + 'dependencies = ["ruff"]\n' + ) + names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content)) + assert names == ["pytest", "pytest-cov", "ruff"] + + def test_pyproject_uv_dev_deps_extracted(self) -> None: + """[tool.uv] dev-dependencies list of PEP 508 strings is scanned.""" + content = ( + '[project]\nname = "mypkg"\ndependencies = ["httpx"]\n' + "[tool.uv]\n" + 'dev-dependencies = ["ruff>=0.3", "pytest"]\n' + ) + names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content)) + assert names == ["httpx", "pytest", "ruff"] + + def test_pyproject_tool_tables_no_false_positives_on_config_keys(self) -> None: + """Non-dependency tool config sections do not produce package findings.""" + content = ( + "[tool.black]\n" + "line-length = 88\n" + "[tool.mypy]\n" + 'python_version = "3.11"\n' + "strict = true\n" + ) + assert sc_mod._extract_packages_from_pyproject(content) == [] + # ── Supply Chain Safe Patterns (SC2) ───────────────────────────────────