Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/skillspector/nodes/analyzers/static_patterns_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,32 @@ def _is_typosquat(pkg_name: str, popular: set[str], max_distance: int = 2) -> st
}


def _pinned_version(operator: str | None, version: str | None) -> str | None:
"""Return *version* only when the specifier pins one concrete release.

A vulnerability lookup answers "is THIS release affected?". That question is only
meaningful when the manifest admits exactly one release. Under PEP 440 that is ``==``
with a fully concrete version: floors (``>=``, ``>``), caps (``<=``, ``<``), exclusions
(``!=``), compatible releases (``~=``) and wildcard equality (``==1.*``) all admit more
than one, so the installed version is unknown and must not be passed off as a pin.
"""
if operator != "==" or not version or "*" in version:
return None
return version


def _pinned_npm_version(spec: str) -> str | None:
"""Return the pinned version of an npm dependency spec, or None for any range.

npm defaults to caret ranges, so ``"^1.8.3"`` is *not* a pin: stripping the operator
turns a range into a concrete release that the project may never install.
"""
candidate = spec.strip()
if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", candidate):
return candidate
return None


def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | None, int]]:
"""Extract (package_name, version_or_None, line_number) from requirements.txt format."""
results: list[tuple[str, str | None, int]] = []
Expand All @@ -427,7 +453,7 @@ def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | N
m = re.match(r"^([a-zA-Z][a-zA-Z0-9._-]*)(?:\[.*?\])?\s*(?:([=<>!~]=?)\s*([\d.*]+))?", line)
if m:
name = m.group(1)
version = m.group(3) if m.group(2) else None
version = _pinned_version(m.group(2), m.group(3))
results.append((name, version, i))
return results

Expand All @@ -448,8 +474,7 @@ def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | N
m = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
if m:
name = m.group(1)
ver_str = m.group(2).lstrip("^~>=<")
version = ver_str if re.match(r"^\d", ver_str) else None
version = _pinned_npm_version(m.group(2))
results.append((name, version, i))
return results

Expand Down Expand Up @@ -495,7 +520,7 @@ def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None
if not m:
continue
name = m.group(1)
version = m.group(3) if m.group(2) in ("==", "<=") else None
version = _pinned_version(m.group(2), m.group(3))
idx = content.find(spec)
line_num = get_line_number(content, idx) if idx >= 0 else 1
results.append((name, version, line_num))
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/test_patterns_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,83 @@ def test_extract_packages_requirements(self) -> None:
assert "numpy" in names
assert "flask" in names

def test_pinned_version_only_accepts_exact_concrete_pins(self) -> None:
# A vulnerability lookup asks "is THIS release affected?", which is only meaningful
# when the manifest admits exactly one release. Everything else must yield None.
assert sc_mod._pinned_version("==", "2.31.0") == "2.31.0"
assert sc_mod._pinned_version("==", "1.*") is None # wildcard equality
assert sc_mod._pinned_version("<=", "8.1.0") is None # cap: admits every earlier
assert sc_mod._pinned_version("<", "8.1.0") is None
assert sc_mod._pinned_version(">=", "10.0.0") is None # floor
assert sc_mod._pinned_version(">", "10.0.0") is None
assert sc_mod._pinned_version("~=", "1.26.0") is None # compatible release
assert sc_mod._pinned_version("!=", "3.0.0") is None # exclusion
assert sc_mod._pinned_version(None, None) is None # bare dependency

def test_pinned_npm_version_rejects_ranges(self) -> None:
# npm defaults to caret ranges: stripping the operator turns a range into a concrete
# release the project may never install (regression: "^1.8.3" -> "1.8.3").
assert sc_mod._pinned_npm_version("4.17.21") == "4.17.21"
assert sc_mod._pinned_npm_version("1.2.3-rc.1") == "1.2.3-rc.1"
assert sc_mod._pinned_npm_version("^1.8.3") is None
assert sc_mod._pinned_npm_version("~4.18.0") is None
assert sc_mod._pinned_npm_version(">=1.2.3") is None
assert sc_mod._pinned_npm_version("1.x") is None
assert sc_mod._pinned_npm_version("*") is None
assert sc_mod._pinned_npm_version(">=1.2.3 <2.0.0") is None
assert sc_mod._pinned_npm_version("") is None

def test_extract_packages_requirements_specifier_is_not_a_pin(self) -> None:
# Regression: any specifier was treated as "==", so the floor "pillow>=10.0.0" was
# scanned as the exact release 10.0.0 and flagged with that release's CVEs.
content = (
"requests==2.31.0\n" # exact pin -> kept
"pillow>=10.0.0\n" # floor -> None
"click<=8.1.0\n" # cap -> None
"urllib3~=1.26.0\n" # compatible -> None
"jinja2!=3.0.0\n" # exclusion -> None
"boto3==1.*\n" # wildcard -> None
"flask\n" # unpinned -> None
)
versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_requirements(content)}
assert versions["requests"] == "2.31.0"
assert versions["pillow"] is None
assert versions["click"] is None
assert versions["urllib3"] is None
assert versions["jinja2"] is None
assert versions["boto3"] is None
assert versions["flask"] is None

def test_extract_packages_pyproject_specifier_is_not_a_pin(self) -> None:
content = (
"[build-system]\n"
'requires = ["setuptools>=61", "wheel==0.42.0"]\n'
"[project]\n"
'dependencies = ["httpx<=0.27.0", "rich==13.*"]\n'
)
versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_pyproject(content)}
assert versions["wheel"] == "0.42.0"
assert versions["setuptools"] is None
assert versions["httpx"] is None
assert versions["rich"] is None

def test_extract_packages_package_json_caret_is_not_a_pin(self) -> None:
content = (
"{\n"
' "dependencies": {\n'
' "shell-quote": "^1.8.3",\n'
' "lodash": "4.17.21",\n'
' "semver": "~7.5.0",\n'
' "glob": "*"\n'
" }\n"
"}"
)
versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_package_json(content)}
assert versions["lodash"] == "4.17.21"
assert versions["shell-quote"] is None
assert versions["semver"] is None
assert versions["glob"] is None

def test_extract_packages_package_json(self) -> None:
content = (
'{\n "dependencies": {\n "express": "^4.18.0",\n "lodash": "4.17.21"\n }\n}'
Expand Down