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 docs/ANALYSIS_RESOURCE_BOUNDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ malicious evasion. Keep required references and use the reason to choose the fix

| Reason | Next step |
|---|---|
| `dependency_parse_error` | Correct the malformed dependency manifest or lockfile, then rerun the scan. |
| `static_parse_limit` | Inspect the expression and analyzer. If valid source is misinterpreted, correct or update the scanner and rerun. |
| `read_error`, `stat_error`, `file_disappeared`, `missing_file_cache` | Ensure the resolved target remains readable throughout the scan. |
| `size_limit`, `runtime_limit` | Review the reported bounds and input size; distinguish a scanner performance problem from a legitimate resource ceiling. |
Expand Down
4 changes: 2 additions & 2 deletions src/skillspector/dependency_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1335,7 +1335,7 @@ def _parse_poetry(
scan.check()
try:
parsed = tomllib.loads(content)
except tomllib.TOMLDecodeError:
except (ValueError, RecursionError):
return
tool = parsed.get("tool", {})
if not isinstance(tool, dict):
Expand Down Expand Up @@ -1427,7 +1427,7 @@ def _parse_cargo(
scan.check()
try:
parsed = tomllib.loads(content)
except tomllib.TOMLDecodeError:
except (ValueError, RecursionError):
return
sources = parsed.get("source", {})
if isinstance(sources, dict):
Expand Down
5 changes: 4 additions & 1 deletion src/skillspector/inspection_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ class LedgerReason(StrEnum):
REFERENCE_EXTRACTION_LIMIT = "reference_extraction_limit"
REFERENCE_MISSING = "reference_missing"
REFERENCE_UNRESOLVED = "reference_unresolved"
MANIFEST_PARSE_ERROR = "manifest_parse_error"
DEPENDENCY_PARSE_ERROR = "dependency_parse_error"
MANIFEST_PARSE_ERROR = "manifest_parse_error"
MANIFEST_PARSE_LIMIT = "manifest_parse_limit"
ARTIFACT_COUNT_LIMIT = "artifact_count_limit"
TRAVERSAL_DEPTH_LIMIT = "traversal_depth_limit"
Expand Down Expand Up @@ -231,6 +231,9 @@ def only_missing_reference_exceptions(exceptions: object) -> bool:
"A local path-like reference matched more than one bundled artifact"
" and could not be resolved to a single target."
),
LedgerReason.DEPENDENCY_PARSE_ERROR: (
"Dependency metadata could not be parsed into supported registry coordinates."
),
LedgerReason.MANIFEST_PARSE_ERROR: (
"Manifest frontmatter is malformed or uses an unsupported value shape."
),
Expand Down
138 changes: 97 additions & 41 deletions src/skillspector/nodes/analyzers/static_patterns_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,7 @@ def _extract_packages_from_package_json(
content: str,
*,
limit: int | None = None,
on_parse_error: Callable[[str], None] | None = None,
) -> list[tuple[str, str | None, int]]:
"""Extract (package_name, version_or_None, line_number) from package.json content.

Expand All @@ -1510,7 +1511,9 @@ def _extract_packages_from_package_json(
return []
try:
data = json.loads(content)
except (ValueError, TypeError):
except (ValueError, TypeError) as error:
if on_parse_error is not None:
on_parse_error(type(error).__name__)
Comment on lines +1514 to +1516

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we let zero-byte JSON manifests reach this callback? The node skips them at if not content, so an empty package.json, package-lock.json, or npm-shrinkwrap.json still reports complete and exits 0 with --fail-on-incomplete on this revision. The detailed parser reports the error correctly when called directly. Please distinguish a missing cache entry from an empty string and add full-node or CLI coverage, while preserving the valid empty-TOML controls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on 6a31b319: the node distinguishes a missing cache entry from present empty content, so zero-byte JSON manifests reach the parse-error handler while empty TOML remains valid. The 29 October review confirms those controls; all six hosted checks pass on this head.

return _extract_packages_from_package_json_scan(content, limit=limit)
if not isinstance(data, dict):
return []
Expand Down Expand Up @@ -1554,6 +1557,7 @@ def _extract_packages_from_pyproject(
content: str,
*,
limit: int | None = None,
on_parse_error: Callable[[str], None] | None = None,
) -> list[tuple[str, str | None, int]]:
"""Extract (package_name, version_or_None, line_number) from pyproject.toml.

Expand All @@ -1566,7 +1570,9 @@ def _extract_packages_from_pyproject(
return []
try:
data = tomllib.loads(content)
except tomllib.TOMLDecodeError:
except (ValueError, RecursionError) as error:
if on_parse_error is not None:
on_parse_error(type(error).__name__)
return []

specs: list[str] = []
Expand Down Expand Up @@ -1826,9 +1832,28 @@ def _normalize_package_name(name: str) -> str:
return name.lower().replace("_", "-")


def _dependency_filename(file_path: str) -> str:
"""Return a normalized basename so documentation is not mistaken for a manifest."""
return file_path.replace("\\", "/").rsplit("/", 1)[-1].lower()


def _is_requirements_file(file_path: str) -> bool:
normalized_path = file_path.replace("\\", "/").lower()
path_parts = normalized_path.split("/")
filename = path_parts[-1]
if filename in {"requirements", "requirements.txt", "requirements.in"} or re.fullmatch(
r"requirements[-_][a-z0-9_-]+\.(?:txt|in)", filename
):
return True
return "requirements" in path_parts[:-1] and bool(
re.fullmatch(r"[a-z0-9][a-z0-9_-]*\.(?:txt|in)", filename)
)


def _is_python_lockfile(file_path: str) -> bool:
lower_path = file_path.lower()
return "uv.lock" in lower_path or "poetry.lock" in lower_path or _is_pipenv_lockfile(file_path)
return _dependency_filename(file_path) in {"uv.lock", "poetry.lock"} or _is_pipenv_lockfile(
file_path
)


def _normalize_npm_package_name(name: str) -> str:
Expand All @@ -1843,8 +1868,18 @@ def _normalize_npm_package_name(name: str) -> str:


def _is_npm_lockfile(file_path: str) -> bool:
lower_path = file_path.lower()
return lower_path.endswith(("package-lock.json", "npm-shrinkwrap.json"))
return _dependency_filename(file_path) in {"package-lock.json", "npm-shrinkwrap.json"}


def _is_dependency_manifest(file_path: str) -> bool:
"""Recognize supported manifest names, not prose files that merely mention them."""
filename = _dependency_filename(file_path)
return (
_is_requirements_file(file_path)
or filename in {"package.json", "pyproject.toml", "setup.py", "pipfile", "pipfile.lock"}
or _is_python_lockfile(file_path)
or _is_npm_lockfile(file_path)
)


def _npm_lock_line_index(content: str) -> dict[str, int]:
Expand All @@ -1864,7 +1899,11 @@ def _npm_lock_line_index(content: str) -> dict[str, int]:
return index


def _npm_lock_entries(content: str) -> list[tuple[str, str, int, int]]:
def _npm_lock_entries(
content: str,
*,
on_parse_error: Callable[[str], None] | None = None,
) -> list[tuple[str, str, int, int]]:
"""Extract (name, version, line, depth) for every install recorded in an npm lockfile.

Covers both layouts: ``lockfileVersion`` 2/3 keep every install under ``packages`` keyed by
Expand All @@ -1881,7 +1920,11 @@ def _npm_lock_entries(content: str) -> list[tuple[str, str, int, int]]:
"""
try:
data = json.loads(content)
except (ValueError, TypeError):
except (ValueError, RecursionError) as error:
if on_parse_error is not None:
on_parse_error(type(error).__name__)
return []
except TypeError:
return []
if not isinstance(data, dict):
return []
Expand Down Expand Up @@ -1931,12 +1974,14 @@ def _extract_packages_from_npm_lock(
content: str,
*,
limit: int | None = None,
on_parse_error: Callable[[str], None] | None = None,
) -> list[tuple[str, str | None, int]]:
"""Extract exact package versions from an npm lockfile."""
if limit is not None and limit <= 0:
return []
found: list[tuple[str, str | None, int]] = [
(name, version, line) for name, version, line, _depth in _npm_lock_entries(content)
(name, version, line)
for name, version, line, _depth in _npm_lock_entries(content, on_parse_error=on_parse_error)
]
return found if limit is None else found[:limit]

Expand All @@ -1945,13 +1990,16 @@ def _extract_packages_from_toml_lock(
content: str,
*,
limit: int | None = None,
on_parse_error: Callable[[str], None] | None = None,
) -> list[tuple[str, str | None, int]]:
"""Extract exact package versions from TOML lockfiles such as uv.lock and poetry.lock."""
if limit is not None and limit <= 0:
return []
try:
data = tomllib.loads(content)
except tomllib.TOMLDecodeError:
except (ValueError, RecursionError) as error:
if on_parse_error is not None:
on_parse_error(type(error).__name__)
return []
packages = data.get("package")
if not isinstance(packages, list):
Expand Down Expand Up @@ -2154,7 +2202,7 @@ def _collect_npm_locked_versions(
if timeout_seconds is not None and time.monotonic() - started_at >= timeout_seconds:
break
content = file_cache.get(path)
if not content:
if content is None:
continue
files_read += 1
for name, version, _line_num, depth in _npm_lock_entries(content):
Expand Down Expand Up @@ -2383,10 +2431,7 @@ def line_number(start: int) -> int:

tag = [PatternCategory.SUPPLY_CHAIN.value]

is_dep_file = any(
n in file_path.lower()
for n in ["requirements", "package.json", "pyproject.toml", "setup.py", "pipfile"]
)
is_dep_file = _is_dependency_manifest(file_path)
if is_dep_file:
for pattern, confidence in SC1_PATTERNS:
matches = (
Expand Down Expand Up @@ -3138,14 +3183,15 @@ def _analyze_dependencies_detailed(
limitations: list[OsvQueryLimitation] = []
tag = [PatternCategory.SUPPLY_CHAIN.value]

lower_path = file_path.lower()
is_lockfile = _is_python_lockfile(lower_path)
is_npm_lock = _is_npm_lockfile(lower_path)
filename = _dependency_filename(file_path)
is_lockfile = _is_python_lockfile(file_path)
is_npm_lock = _is_npm_lockfile(file_path)
is_python_dep = (
any(n in lower_path for n in ["requirements", "pyproject.toml", "setup.py", "pipfile"])
_is_requirements_file(file_path)
or filename in {"pyproject.toml", "setup.py", "pipfile", "pipfile.lock"}
or is_lockfile
)
is_npm_dep = "package.json" in lower_path or is_npm_lock
is_npm_dep = filename == "package.json" or is_npm_lock

if not is_python_dep and not is_npm_dep:
return findings, limitations, 0
Expand All @@ -3159,18 +3205,34 @@ def _analyze_dependencies_detailed(
)
extraction_limit = package_limit + 1

def record_parse_error(error_class: str) -> None:
limitations.append(
OsvQueryLimitation(
reason=LedgerReason.DEPENDENCY_PARSE_ERROR,
error_class=error_class,
)
)

if is_python_dep:
if Path(file_path).name.lower() in {"pipfile", "pipfile.lock"}:
if filename in {"pipfile", "pipfile.lock"}:
packages, parse_limitations = _extract_packages_from_pipenv(
content,
is_lockfile=_is_pipenv_lockfile(file_path),
limit=extraction_limit,
)
limitations.extend(parse_limitations)
elif "pyproject.toml" in lower_path:
packages = _extract_packages_from_pyproject(content, limit=extraction_limit)
elif filename == "pyproject.toml":
packages = _extract_packages_from_pyproject(
content,
limit=extraction_limit,
on_parse_error=record_parse_error,
Comment on lines +3225 to +3228

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we limit this parse-error reporting to actual manifest files? A skill containing ordinary prose in docs/pyproject.toml.md now gets dependency_parse_error and exits 1 with --fail-on-incomplete; the base revision completes with exit 0. The same happens with docs/package.json.md and docs/uv.lock.md. The substring checks route these Markdown documents into the parsers, so the new callback turns unrelated documentation into a scan failure. Please distinguish actual manifests and supported templates from ordinary documentation before recording the limitation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on 6a31b319: manifest matching keeps requirements/dev.txt and requirements/base.txt in scope while excluding Markdown documentation such as these files. The 29 October review confirms the SC4 advisory cases are retained; all six hosted checks pass on this head.

)
elif is_lockfile:
packages = _extract_packages_from_toml_lock(content, limit=extraction_limit)
packages = _extract_packages_from_toml_lock(
content,
limit=extraction_limit,
on_parse_error=record_parse_error,
)
else:
packages, oversized_spec = _extract_packages_from_requirements_detailed(
content,
Expand All @@ -3192,10 +3254,18 @@ def _analyze_dependencies_detailed(
known_legit = _KNOWN_LEGIT_PYPI
else:
if is_npm_lock:
packages = _extract_packages_from_npm_lock(content, limit=extraction_limit)
packages = _extract_packages_from_npm_lock(
content,
limit=extraction_limit,
on_parse_error=record_parse_error,
)
else:
packages = _apply_locked_versions(
_extract_packages_from_package_json(content, limit=extraction_limit),
_extract_packages_from_package_json(
Comment thread
efegokdemir marked this conversation as resolved.
Resolved
content,
limit=extraction_limit,
on_parse_error=record_parse_error,
),
npm_locked_versions,
_normalize_npm_package_name,
)
Expand Down Expand Up @@ -4124,21 +4194,7 @@ def dependency_remaining_seconds() -> float:
dependency_findings_seen = 0
osv_budget = OsvQueryBudget.create(dependency_remaining_seconds())
for path in components:
lower_path = path.lower()
is_dep_file = any(
n in lower_path
for n in [
"requirements",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"pyproject.toml",
"setup.py",
"pipfile",
"uv.lock",
"poetry.lock",
]
)
is_dep_file = _is_dependency_manifest(path)
if not is_dep_file:
continue
dependency_files_seen += 1
Expand Down
33 changes: 33 additions & 0 deletions tests/nodes/analyzers/test_dependency_source_review_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,39 @@ def test_non_mapping_toml_tool_preserves_earlier_supply_chain_results(monkeypatc
)


def test_oversized_toml_integer_does_not_drop_existing_supply_chain_findings(monkeypatch):
earlier = Finding(
rule_id="SC5", severity="HIGH", file="requirements.txt", message="Existing risk"
)
monkeypatch.setattr(
supply_chain.static_runner,
"run_static_patterns_with_ledger",
lambda *_args: {
"findings": [earlier],
"inspection_ledger": [],
"analyzer_status_events": [],
},
)
files = {
"requirements.txt": "risky-package==1.0.0\n",
"pyproject.toml": "[project]\nname = " + "9" * 5_000,
}

response = supply_chain.node({"components": list(files), "file_cache": files})

assert response["findings"] == [earlier]
assert not any(
event["outcome"] is LedgerOutcome.FAILED for event in response["inspection_ledger"]
)


@pytest.mark.parametrize("path", ["pyproject.toml", ".cargo/config.toml"])
def test_oversized_toml_integer_does_not_abort_dependency_source_scan(path):
content = "value = " + "9" * 5_000

assert analyze_dependency_sources([path], {path: content}) == []


@pytest.mark.parametrize(
("target", "canonical", "body"),
[
Expand Down
Loading
Loading