diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 3d9f8382..a751186c 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -486,6 +486,36 @@ def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None return results + +def _extract_packages_from_toml_lock(content: str) -> list[tuple[str, str | None, int]]: + """Extract exact package versions from TOML lockfiles such as uv.lock and poetry.lock.""" + try: + data = tomllib.loads(content) + except tomllib.TOMLDecodeError: + return [] + + packages = data.get("package") + if not isinstance(packages, list): + return [] + + results: list[tuple[str, str | None, int]] = [] + for package in packages: + if not isinstance(package, dict): + continue + + name = package.get("name") + version = package.get("version") + if not isinstance(name, str) or not name.strip(): + continue + + version_value = version.strip() if isinstance(version, str) and version.strip() else None + marker = f'name = "{name}"' + idx = content.find(marker) + line_num = get_line_number(content, idx) if idx >= 0 else 1 + results.append((name, version_value, line_num)) + + return results + def _version_lt(v1: str, v2: str) -> bool: """Simple version comparison: True if v1 < v2 (numeric tuple comparison).""" @@ -517,7 +547,7 @@ def ctx(start: int) -> str: is_dep_file = any( n in file_path.lower() - for n in ["requirements", "package.json", "pyproject.toml", "setup.py", "pipfile"] + for n in ["requirements", "package.json", "pyproject.toml", "setup.py", "pipfile", "uv.lock", "poetry.lock"] ) if is_dep_file: for pattern, confidence in SC1_PATTERNS: @@ -761,7 +791,7 @@ def _analyze_dependencies( lower_path = file_path.lower() is_python_dep = any( - n in lower_path for n in ["requirements", "pyproject.toml", "setup.py", "pipfile"] + n in lower_path for n in ["requirements", "pyproject.toml", "setup.py", "pipfile", "uv.lock", "poetry.lock"] ) is_npm_dep = "package.json" in lower_path @@ -771,6 +801,8 @@ def _analyze_dependencies( if is_python_dep: if "pyproject.toml" in lower_path: packages = _extract_packages_from_pyproject(content) + elif "uv.lock" in lower_path or "poetry.lock" in lower_path: + packages = _extract_packages_from_toml_lock(content) else: packages = _extract_packages_from_requirements(content) ecosystem = ECOSYSTEM_PYPI @@ -963,7 +995,7 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: lower_path = path.lower() is_dep_file = any( n in lower_path - for n in ["requirements", "package.json", "pyproject.toml", "setup.py", "pipfile"] + for n in ["requirements", "package.json", "pyproject.toml", "setup.py", "pipfile", "uv.lock", "poetry.lock"] ) if not is_dep_file: continue diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py new file mode 100644 index 00000000..5b10566b --- /dev/null +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -0,0 +1,38 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +from skillspector.nodes.analyzers import static_patterns_supply_chain as supply_chain +def test_uv_lock_versions_are_passed_to_osv(monkeypatch): + seen = {} + def fake_query_batch(packages, ecosystem): + seen["packages"] = packages + seen["ecosystem"] = ecosystem + return [[] for _ in packages] + monkeypatch.setattr(supply_chain, "query_batch", fake_query_batch) + content = """ +version = 1 +[[package]] +name = "mlx" +version = "0.31.2" +[[package]] +name = "requests" +version = "2.31.0" +""" + supply_chain._analyze_dependencies(content, "uv.lock") + assert seen["ecosystem"] == supply_chain.ECOSYSTEM_PYPI + assert ("mlx", "0.31.2") in seen["packages"] + assert ("requests", "2.31.0") in seen["packages"] +def test_poetry_lock_versions_are_passed_to_osv(monkeypatch): + seen = {} + def fake_query_batch(packages, ecosystem): + seen["packages"] = packages + seen["ecosystem"] = ecosystem + return [[] for _ in packages] + monkeypatch.setattr(supply_chain, "query_batch", fake_query_batch) + content = """ +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A fast template engine." +""" + supply_chain._analyze_dependencies(content, "poetry.lock") + assert seen["ecosystem"] == supply_chain.ECOSYSTEM_PYPI + assert ("jinja2", "3.1.6") in seen["packages"]