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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
### 2.12.0 (Unreleased)
### Features/Bug Fixes
* fix(coverage): account for successfully inspected linked ZIP containers while retaining member failures and archive limits
* fix(supply-chain): resolve npm manifest dependencies within their owning lockfile and workspace installation paths
* fix(static): stop Markdown code fence lines from reading as an open quote that merges consecutive shell lines into one marker directive (#868)
* fix(ra1): treat a prohibition on recommending a self-update ("Do not recommend self-update") as the prohibition it is, not as an RA1 self-modification instruction (#928)
* fix(analyzer): stop PowerShell comments, `<# #>` blocks, backtick line continuations and display-only `Write-Host`/`Write-Verbose`-style messages from reporting a false `static_parse_limit`; payloads for `bash -c`, `Invoke-Expression`, script blocks and call operators, subexpressions inside messages, and any source whose token ownership cannot be proven keep their existing bounded checks and partial status (#711)
Expand Down
136 changes: 110 additions & 26 deletions src/skillspector/nodes/analyzers/static_patterns_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@
import time
import tomllib
from bisect import bisect_right
from collections.abc import Callable, Iterator
from collections import ChainMap
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse

from packaging.requirements import InvalidRequirement, Requirement
Expand Down Expand Up @@ -2036,7 +2037,7 @@ def _extract_packages_from_toml_lock(

def _apply_locked_versions(
packages: list[tuple[str, str | None, int]],
locked_versions: dict[str, str] | None,
locked_versions: Mapping[str, str] | None,
normalize: Callable[[str], str] = _normalize_package_name,
) -> list[tuple[str, str | None, int]]:
"""Prefer lockfile versions for manifest dependencies without exact versions."""
Expand Down Expand Up @@ -2189,20 +2190,29 @@ def _collect_npm_locked_versions(
*,
max_files: int | None = None,
timeout_seconds: float | None = None,
) -> dict[str, str]:
"""Build package -> exact version map from npm lockfiles in the project.

Kept separate from the Python map: the two ecosystems share package names (``semver``,
``packaging``, ``requests`` all exist in both), so a single map would resolve a PyPI
dependency against an npm version, and vice versa.
) -> dict[str, Mapping[str, str]]:
"""Resolve manifest pins within their own lockfile's installation tree.

Bounded by the same file count and clock the Python collector is bounded by. A lockfile is
attacker-shaped input — a skill can ship one — and an unbounded parse next to a bounded one
is not half-safe, it is the way in: the cap that matters is the smallest one, and here that
would have been no cap at all.
Workspace manifests may use hoisted installs from their lock root, but unrelated
projects must never borrow those pins. Keep each lockfile's independent vulnerability
scan separate: nested installs still need auditing even when no manifest resolves to them.
"""
locked_versions: dict[str, str] = {}
depths: dict[str, int] = {}
locked_versions: dict[str, Mapping[str, str]] = {}
owners = {
str(Path(path).parent): len(Path(path).parent.parts)
for path in components
if _is_npm_lockfile(path)
}
shrinkwrap_dirs = {
str(Path(path).parent)
for path in components
if _dependency_filename(path) == "npm-shrinkwrap.json"
}
manifest_dirs = {
str(Path(path).parent)
for path in components
if _dependency_filename(path) == "package.json"
}
started_at = time.monotonic()
files_read = 0
for path in components:
Expand All @@ -2212,18 +2222,92 @@ def _collect_npm_locked_versions(
break
if timeout_seconds is not None and time.monotonic() - started_at >= timeout_seconds:
break
root = Path(path).parent
if _dependency_filename(path) == "package-lock.json" and str(root) in shrinkwrap_dirs:
continue # npm gives shrinkwrap precedence when both files exist.
content = file_cache.get(path)
if content is None:
continue
files_read += 1
for name, version, _line_num, depth in _npm_lock_entries(content):
key = _normalize_npm_package_name(name)
# A manifest range names a *direct* dependency, so it resolves to the top-level
# install. Nested copies exist for other packages' constraints; answering with one
# would report a version this manifest never asked for.
if key not in depths or depth < depths[key]:
locked_versions[key] = version
depths[key] = depth
try:
data = json.loads(content)
except (ValueError, TypeError, RecursionError):
continue
if not isinstance(data, dict):
continue
installs: dict[PurePosixPath, dict[str, str]] = {}
projects = {PurePosixPath(".")}
packages = data.get("packages")
if isinstance(packages, dict):
for location, entry in packages.items():
relative = PurePosixPath(location)
if (
not location
or relative.is_absolute()
or ".." in relative.parts
or "\\" in location
):
continue
if not isinstance(entry, dict):
continue
if str(root / relative) in manifest_dirs:
projects.add(relative)
owner, separator, name = location.rpartition("node_modules/")
version = entry.get("version")
registry_name = entry.get("name")
if (
isinstance(registry_name, str)
and registry_name.strip()
and registry_name != name
):
continue # Alias slots must not borrow their target package's version.
if (
separator
and (not owner or owner.endswith("/"))
and isinstance(version, str)
and version.strip()
):
installs.setdefault(PurePosixPath(owner), {})[
_normalize_npm_package_name(name)
] = version.strip()
elif isinstance(data.get("dependencies"), dict):
trees = {PurePosixPath("."): data["dependencies"]}
for entry in data["dependencies"].values():
version = entry.get("version") if isinstance(entry, dict) else None
if not isinstance(version, str) or not version.startswith("file:"):
continue
location = version[5:]
relative = PurePosixPath(location)
if (
not location
or relative == PurePosixPath(".")
or relative.is_absolute()
or ".." in relative.parts
or "\\" in location
or str(root / relative) not in manifest_dirs
):
continue
# v1 records workspace links here instead of in the packages table.
projects.add(relative)
trees[relative] = entry.get("dependencies", {})
for project, dependencies in trees.items():
if isinstance(dependencies, dict):
installs[project] = {
_normalize_npm_package_name(name): entry["version"].strip()
for name, entry in dependencies.items()
if isinstance(entry, dict)
and isinstance(entry.get("version"), str)
and entry["version"].strip()
}
for project in projects:
scope = str(root / project)
depth = len(root.parts)
if depth < owners.get(scope, -1):
continue
owners[scope] = depth
locked_versions[scope] = ChainMap(
*(installs[parent] for parent in (project, *project.parents) if parent in installs)
)
return locked_versions


Expand Down Expand Up @@ -3167,7 +3251,7 @@ def _analyze_dependencies(
content: str,
file_path: str,
locked_versions: dict[str, str] | None = None,
npm_locked_versions: dict[str, str] | None = None,
npm_locked_versions: Mapping[str, str] | None = None,
) -> list[AnalyzerFinding]:
"""Run SC4/SC5/SC6 checks on dependency files."""
findings, _limitations, _packages_seen = _analyze_dependencies_detailed(
Expand All @@ -3183,7 +3267,7 @@ def _analyze_dependencies_detailed(
content: str,
file_path: str,
locked_versions: dict[str, str] | None = None,
npm_locked_versions: dict[str, str] | None = None,
npm_locked_versions: Mapping[str, str] | None = None,
*,
max_packages: int | None = None,
max_findings: int | None = None,
Expand Down Expand Up @@ -4270,7 +4354,7 @@ def dependency_remaining_seconds() -> float:
content,
path,
locked_versions.get(_python_lock_scope(path)),
npm_locked_versions,
npm_locked_versions.get(str(Path(path).parent)),
max_packages=min(MAX_DEPENDENCY_PACKAGES_PER_FILE, remaining_packages),
max_findings=min(MAX_DEPENDENCY_FINDINGS_PER_FILE, remaining_dependency_findings),
timeout_seconds=shared_remaining,
Expand Down
Loading
Loading