From b7af89eaf8beedda8fb2a9b162f187a572fc43c5 Mon Sep 17 00:00:00 2001 From: Efe Date: Tue, 6 Oct 2026 16:33:49 +0300 Subject: [PATCH 1/4] fix(analyzer): report malformed dependency manifests Signed-off-by: Efe --- .../analyzers/static_patterns_supply_chain.py | 41 ++++++++++++--- ..._static_patterns_supply_chain_lockfiles.py | 50 +++++++++++++++++++ tests/unit/test_cli.py | 44 ++++++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 94a02b7c7..95126c707 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -1354,6 +1354,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. @@ -1366,7 +1367,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__) return _extract_packages_from_package_json_scan(content, limit=limit) if not isinstance(data, dict): return [] @@ -1405,6 +1408,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. @@ -1417,7 +1421,9 @@ def _extract_packages_from_pyproject( return [] try: data = tomllib.loads(content) - except tomllib.TOMLDecodeError: + except tomllib.TOMLDecodeError as error: + if on_parse_error is not None: + on_parse_error(type(error).__name__) return [] specs: list[str] = [] @@ -1595,13 +1601,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 tomllib.TOMLDecodeError 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): @@ -2755,11 +2764,27 @@ def _analyze_dependencies_detailed( ) extraction_limit = package_limit + 1 + def record_parse_error(error_class: str) -> None: + limitations.append( + OsvQueryLimitation( + reason=LedgerReason.STATIC_PARSE_LIMIT, + error_class=error_class, + ) + ) + if is_python_dep: if "pyproject.toml" in lower_path: - packages = _extract_packages_from_pyproject(content, limit=extraction_limit) + packages = _extract_packages_from_pyproject( + content, + limit=extraction_limit, + on_parse_error=record_parse_error, + ) 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, @@ -2784,7 +2809,11 @@ def _analyze_dependencies_detailed( packages = _extract_packages_from_npm_lock(content, limit=extraction_limit) else: packages = _apply_locked_versions( - _extract_packages_from_package_json(content, limit=extraction_limit), + _extract_packages_from_package_json( + content, + limit=extraction_limit, + on_parse_error=record_parse_error, + ), npm_locked_versions, _normalize_npm_package_name, ) diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index a6a000f48..503a40d14 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest + from skillspector.inspection_ledger import LedgerOutcome, LedgerReason from skillspector.nodes.analyzers import static_patterns_supply_chain as supply_chain from skillspector.nodes.analyzers.osv_client import OsvQueryLimitation, QueryBatchResults @@ -106,6 +108,54 @@ def test_toml_lock_parser_returns_empty_for_malformed_toml(): assert supply_chain._extract_packages_from_toml_lock(content) == [] +@pytest.mark.parametrize( + ("path", "content"), + [ + ("pyproject.toml", "[project]\ndependencies = ["), + ("package.json", '{"dependencies": {'), + ("uv.lock", '[[package]\nname = "broken"'), + ], +) +def test_malformed_dependency_manifests_are_reported_as_partial(path, content): + _findings, limitations, _packages_seen = supply_chain._analyze_dependencies_detailed( + content, + path, + ) + + assert any(item.reason is LedgerReason.STATIC_PARSE_LIMIT for item in limitations) + + +@pytest.mark.parametrize( + ("path", "content"), + [ + ("pyproject.toml", "[project]\nname = 'example'\n"), + ("package.json", '{"name": "example"}'), + ("uv.lock", "version = 1\n"), + ], +) +def test_valid_dependency_free_manifests_remain_complete(path, content): + _findings, limitations, _packages_seen = supply_chain._analyze_dependencies_detailed( + content, + path, + ) + + assert not any(item.reason is LedgerReason.STATIC_PARSE_LIMIT for item in limitations) + + +def test_malformed_package_json_keeps_line_scan_fallback(monkeypatch): + seen = _capture_osv_packages(monkeypatch) + content = '{"dependencies": {\n "demo-package": "1.2.3"\n' + + _findings, limitations, packages_seen = supply_chain._analyze_dependencies_detailed( + content, + "package.json", + ) + + assert packages_seen == 1 + assert ("demo-package", "1.2.3") in seen["packages"] + assert any(item.reason is LedgerReason.STATIC_PARSE_LIMIT for item in limitations) + + def test_toml_lock_parser_keeps_package_without_version(): content = """ [[package]] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 820683d68..e5d39b79b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -408,6 +408,50 @@ def test_cli_scan_local_directory(tmp_path: Path) -> None: assert "scan-test" in result.output or "skill" in result.output +@pytest.mark.parametrize( + ("manifest_path", "manifest", "incomplete"), + [ + ("pyproject.toml", "[project]\ndependencies = [", True), + ("package.json", '{"dependencies": {', True), + ("uv.lock", '[[package]\nname = "broken"', True), + ("pyproject.toml", "[project]\nname = 'example'\n", False), + ("package.json", '{"name": "example"}', False), + ("uv.lock", "version = 1\n", False), + ], +) +def test_cli_reports_malformed_dependency_manifest_completeness( + tmp_path: Path, manifest_path: str, manifest: str, incomplete: bool +) -> None: + """Malformed dependency metadata must affect the strict completeness gate.""" + (tmp_path / "SKILL.md").write_text("---\nname: manifest-test\n---\n# Safe", encoding="utf-8") + (tmp_path / manifest_path).write_text(manifest, encoding="utf-8") + output = tmp_path / "report.json" + + result = runner.invoke( + app, + [ + "scan", + str(tmp_path), + "--format", + "json", + "--no-llm", + "--output", + str(output), + "--fail-on-incomplete", + ], + ) + + assert result.exit_code == (1 if incomplete else 0) + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["analysis_completeness"]["is_complete"] is not incomplete + if incomplete: + assert any( + event["reason_code"] == LedgerReason.STATIC_PARSE_LIMIT.value + and event["path"] == manifest_path + for event in payload["analysis_completeness"]["ledger_exceptions"] + ) + + def test_cli_rejects_symlinked_parent_before_preflight( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From f6090e717cb361371c3c4c4fa6dd811a6c719e57 Mon Sep 17 00:00:00 2001 From: Efe Date: Wed, 14 Oct 2026 06:40:16 +0300 Subject: [PATCH 2/4] fix(analyzer): report malformed manifests as incomplete Signed-off-by: Efe --- docs/ANALYSIS_RESOURCE_BOUNDS.md | 1 + src/skillspector/inspection_ledger.py | 4 +++ .../analyzers/static_patterns_supply_chain.py | 28 ++++++++++++++----- ..._static_patterns_supply_chain_lockfiles.py | 24 ++++++++++++++-- tests/unit/test_cli.py | 4 ++- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/docs/ANALYSIS_RESOURCE_BOUNDS.md b/docs/ANALYSIS_RESOURCE_BOUNDS.md index a7b776564..1cecca85b 100644 --- a/docs/ANALYSIS_RESOURCE_BOUNDS.md +++ b/docs/ANALYSIS_RESOURCE_BOUNDS.md @@ -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. | diff --git a/src/skillspector/inspection_ledger.py b/src/skillspector/inspection_ledger.py index 953ace9f2..95d9cfa25 100644 --- a/src/skillspector/inspection_ledger.py +++ b/src/skillspector/inspection_ledger.py @@ -89,6 +89,7 @@ class LedgerReason(StrEnum): REFERENCE_EXTRACTION_LIMIT = "reference_extraction_limit" REFERENCE_MISSING = "reference_missing" REFERENCE_UNRESOLVED = "reference_unresolved" + DEPENDENCY_PARSE_ERROR = "dependency_parse_error" MANIFEST_PARSE_ERROR = "manifest_parse_error" MANIFEST_PARSE_LIMIT = "manifest_parse_limit" ARTIFACT_COUNT_LIMIT = "artifact_count_limit" @@ -192,6 +193,9 @@ class LedgerReason(StrEnum): "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." ), diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 95126c707..829443653 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -1421,7 +1421,7 @@ def _extract_packages_from_pyproject( return [] try: data = tomllib.loads(content) - except tomllib.TOMLDecodeError as error: + except (ValueError, RecursionError) as error: if on_parse_error is not None: on_parse_error(type(error).__name__) return [] @@ -1520,7 +1520,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 @@ -1537,7 +1541,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 [] @@ -1587,12 +1595,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] @@ -1608,7 +1618,7 @@ def _extract_packages_from_toml_lock( return [] try: data = tomllib.loads(content) - except tomllib.TOMLDecodeError as error: + except (ValueError, RecursionError) as error: if on_parse_error is not None: on_parse_error(type(error).__name__) return [] @@ -2767,7 +2777,7 @@ def _analyze_dependencies_detailed( def record_parse_error(error_class: str) -> None: limitations.append( OsvQueryLimitation( - reason=LedgerReason.STATIC_PARSE_LIMIT, + reason=LedgerReason.DEPENDENCY_PARSE_ERROR, error_class=error_class, ) ) @@ -2806,7 +2816,11 @@ def record_parse_error(error_class: str) -> None: 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( diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index 503a40d14..f72a42697 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -113,6 +113,8 @@ def test_toml_lock_parser_returns_empty_for_malformed_toml(): [ ("pyproject.toml", "[project]\ndependencies = ["), ("package.json", '{"dependencies": {'), + ("package-lock.json", '{"packages": '), + ("npm-shrinkwrap.json", '{"dependencies": '), ("uv.lock", '[[package]\nname = "broken"'), ], ) @@ -122,7 +124,25 @@ def test_malformed_dependency_manifests_are_reported_as_partial(path, content): path, ) - assert any(item.reason is LedgerReason.STATIC_PARSE_LIMIT for item in limitations) + assert any(item.reason is LedgerReason.DEPENDENCY_PARSE_ERROR for item in limitations) + + +@pytest.mark.parametrize( + ("path", "prefix"), + [ + ("pyproject.toml", "[project]\nname = "), + ("uv.lock", "version = "), + ], +) +def test_oversized_toml_integer_is_reported_as_dependency_parse_error(path, prefix): + content = prefix + "9" * 5_000 + + _findings, limitations, _packages_seen = supply_chain._analyze_dependencies_detailed( + content, + path, + ) + + assert any(item.reason is LedgerReason.DEPENDENCY_PARSE_ERROR for item in limitations) @pytest.mark.parametrize( @@ -153,7 +173,7 @@ def test_malformed_package_json_keeps_line_scan_fallback(monkeypatch): assert packages_seen == 1 assert ("demo-package", "1.2.3") in seen["packages"] - assert any(item.reason is LedgerReason.STATIC_PARSE_LIMIT for item in limitations) + assert any(item.reason is LedgerReason.DEPENDENCY_PARSE_ERROR for item in limitations) def test_toml_lock_parser_keeps_package_without_version(): diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index e5d39b79b..028f198eb 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -413,6 +413,8 @@ def test_cli_scan_local_directory(tmp_path: Path) -> None: [ ("pyproject.toml", "[project]\ndependencies = [", True), ("package.json", '{"dependencies": {', True), + ("package-lock.json", '{"packages": ', True), + ("npm-shrinkwrap.json", '{"dependencies": ', True), ("uv.lock", '[[package]\nname = "broken"', True), ("pyproject.toml", "[project]\nname = 'example'\n", False), ("package.json", '{"name": "example"}', False), @@ -446,7 +448,7 @@ def test_cli_reports_malformed_dependency_manifest_completeness( assert payload["analysis_completeness"]["is_complete"] is not incomplete if incomplete: assert any( - event["reason_code"] == LedgerReason.STATIC_PARSE_LIMIT.value + event["reason_code"] == LedgerReason.DEPENDENCY_PARSE_ERROR.value and event["path"] == manifest_path for event in payload["analysis_completeness"]["ledger_exceptions"] ) From eb2fa389fdf76f888c0d83a3373d64a4ee37b32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Sat, 24 Oct 2026 18:00:17 +0300 Subject: [PATCH 3/4] fix(analyzer): handle dependency manifest edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid parsing documentation lookalikes as manifests, report empty JSON manifests, and keep oversized TOML values from aborting analysis. Signed-off-by: Efe Gökdemir --- src/skillspector/dependency_sources.py | 4 +- .../analyzers/static_patterns_supply_chain.py | 69 ++++++++++--------- ...st_dependency_source_review_regressions.py | 33 +++++++++ ..._static_patterns_supply_chain_lockfiles.py | 33 +++++++++ tests/unit/test_cli.py | 13 +++- 5 files changed, 117 insertions(+), 35 deletions(-) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index 0f398a2f8..82490db4c 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -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): @@ -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): diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 829443653..3775c7642 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -1482,9 +1482,20 @@ 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: + filename = _dependency_filename(file_path) + return filename in {"requirements", "requirements.txt", "requirements.in"} or bool( + re.fullmatch(r"requirements[-_][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 + return _dependency_filename(file_path) in {"uv.lock", "poetry.lock"} def _normalize_npm_package_name(name: str) -> str: @@ -1499,8 +1510,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]: @@ -1725,7 +1746,7 @@ def _collect_locked_versions_detailed( ) break content = file_cache.get(path) - if not content: + if content is None: continue remaining = max(0, limit - packages_seen) if remaining <= 0: @@ -1795,7 +1816,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): @@ -2024,10 +2045,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 = ( @@ -2753,14 +2771,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 @@ -2783,7 +2802,7 @@ def record_parse_error(error_class: str) -> None: ) if is_python_dep: - if "pyproject.toml" in lower_path: + if filename == "pyproject.toml": packages = _extract_packages_from_pyproject( content, limit=extraction_limit, @@ -3734,21 +3753,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 @@ -3806,7 +3811,7 @@ def dependency_remaining_seconds() -> float: ) break content = file_cache.get(path) - if not content: + if content is None: continue dep_findings, dependency_limitations, packages_seen = _analyze_dependencies_detailed( content, diff --git a/tests/nodes/analyzers/test_dependency_source_review_regressions.py b/tests/nodes/analyzers/test_dependency_source_review_regressions.py index 2f073ba99..7e0a49f41 100644 --- a/tests/nodes/analyzers/test_dependency_source_review_regressions.py +++ b/tests/nodes/analyzers/test_dependency_source_review_regressions.py @@ -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"), [ diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index f72a42697..b2cdc3dff 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -108,6 +108,39 @@ def test_toml_lock_parser_returns_empty_for_malformed_toml(): assert supply_chain._extract_packages_from_toml_lock(content) == [] +@pytest.mark.parametrize( + ("file_cache", "expected_parse_error"), + [({"package.json": ""}, True), ({}, False)], +) +def test_node_distinguishes_empty_dependency_file_from_missing_cache_entry( + monkeypatch, file_cache, expected_parse_error +): + monkeypatch.setattr( + supply_chain.static_runner, + "run_static_patterns_with_ledger", + lambda _state, _modules: { + "findings": [], + "inspection_ledger": [], + "analyzer_status_events": [], + }, + ) + response = supply_chain.node( + { + "components": ["package.json"], + "file_cache": file_cache, + "local_file_cache": file_cache, + "manifest": {}, + "component_metadata": [], + } + ) + + has_parse_error = any( + event["reason_code"] is LedgerReason.DEPENDENCY_PARSE_ERROR + for event in response["inspection_ledger"] + ) + assert has_parse_error is expected_parse_error + + @pytest.mark.parametrize( ("path", "content"), [ diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 028f198eb..9815ee9e8 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -412,13 +412,22 @@ def test_cli_scan_local_directory(tmp_path: Path) -> None: ("manifest_path", "manifest", "incomplete"), [ ("pyproject.toml", "[project]\ndependencies = [", True), + ("pyproject.toml", "[project]\nname = " + "9" * 5_000, True), ("package.json", '{"dependencies": {', True), + ("package.json", "", True), ("package-lock.json", '{"packages": ', True), + ("package-lock.json", "", True), ("npm-shrinkwrap.json", '{"dependencies": ', True), + ("npm-shrinkwrap.json", "", True), ("uv.lock", '[[package]\nname = "broken"', True), ("pyproject.toml", "[project]\nname = 'example'\n", False), + ("pyproject.toml", "", False), ("package.json", '{"name": "example"}', False), ("uv.lock", "version = 1\n", False), + ("uv.lock", "", False), + ("docs/pyproject.toml.md", "Documentation describing pyproject.toml fields.\n", False), + ("docs/package.json.md", "Documentation describing package.json fields.\n", False), + ("docs/uv.lock.md", "Documentation describing uv.lock fields.\n", False), ], ) def test_cli_reports_malformed_dependency_manifest_completeness( @@ -426,7 +435,9 @@ def test_cli_reports_malformed_dependency_manifest_completeness( ) -> None: """Malformed dependency metadata must affect the strict completeness gate.""" (tmp_path / "SKILL.md").write_text("---\nname: manifest-test\n---\n# Safe", encoding="utf-8") - (tmp_path / manifest_path).write_text(manifest, encoding="utf-8") + manifest_file = tmp_path / manifest_path + manifest_file.parent.mkdir(parents=True, exist_ok=True) + manifest_file.write_text(manifest, encoding="utf-8") output = tmp_path / "report.json" result = runner.invoke( From 6a31b31915b1813c0753e10581c1e0ac2a64a66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Thu, 29 Oct 2026 01:44:19 +0300 Subject: [PATCH 4/4] fix: retain nested requirements manifest coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Efe Gökdemir --- src/skillspector/inspection_ledger.py | 1 - ..._static_patterns_supply_chain_lockfiles.py | 109 ++++++++++++++++-- tests/unit/test_cli.py | 1 + 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/skillspector/inspection_ledger.py b/src/skillspector/inspection_ledger.py index eb8932493..01027d580 100644 --- a/src/skillspector/inspection_ledger.py +++ b/src/skillspector/inspection_ledger.py @@ -103,7 +103,6 @@ class LedgerReason(StrEnum): REFERENCE_UNRESOLVED = "reference_unresolved" DEPENDENCY_PARSE_ERROR = "dependency_parse_error" MANIFEST_PARSE_ERROR = "manifest_parse_error" - DEPENDENCY_PARSE_ERROR = "dependency_parse_error" MANIFEST_PARSE_LIMIT = "manifest_parse_limit" ARTIFACT_COUNT_LIMIT = "artifact_count_limit" TRAVERSAL_DEPTH_LIMIT = "traversal_depth_limit" diff --git a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py index b2cdc3dff..24e07f957 100644 --- a/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py +++ b/tests/nodes/analyzers/test_static_patterns_supply_chain_lockfiles.py @@ -23,7 +23,7 @@ def _capture_osv_packages(monkeypatch): seen = {} - def fake_query_batch(packages, ecosystem): + def fake_query_batch(packages, ecosystem, **_kwargs): seen["packages"] = packages seen["ecosystem"] = ecosystem return [[] for _ in packages] @@ -108,12 +108,10 @@ def test_toml_lock_parser_returns_empty_for_malformed_toml(): assert supply_chain._extract_packages_from_toml_lock(content) == [] -@pytest.mark.parametrize( - ("file_cache", "expected_parse_error"), - [({"package.json": ""}, True), ({}, False)], -) +@pytest.mark.parametrize("path", ["package.json", "package-lock.json", "npm-shrinkwrap.json"]) +@pytest.mark.parametrize("is_cached", [True, False], ids=["present-empty", "missing"]) def test_node_distinguishes_empty_dependency_file_from_missing_cache_entry( - monkeypatch, file_cache, expected_parse_error + monkeypatch, path, is_cached ): monkeypatch.setattr( supply_chain.static_runner, @@ -124,9 +122,10 @@ def test_node_distinguishes_empty_dependency_file_from_missing_cache_entry( "analyzer_status_events": [], }, ) + file_cache = {path: ""} if is_cached else {} response = supply_chain.node( { - "components": ["package.json"], + "components": [path], "file_cache": file_cache, "local_file_cache": file_cache, "manifest": {}, @@ -138,7 +137,101 @@ def test_node_distinguishes_empty_dependency_file_from_missing_cache_entry( event["reason_code"] is LedgerReason.DEPENDENCY_PARSE_ERROR for event in response["inspection_ledger"] ) - assert has_parse_error is expected_parse_error + assert has_parse_error is is_cached + + +def test_node_treats_empty_pyproject_toml_as_valid(monkeypatch): + monkeypatch.setattr( + supply_chain.static_runner, + "run_static_patterns_with_ledger", + lambda _state, _modules: { + "findings": [], + "inspection_ledger": [], + "analyzer_status_events": [], + }, + ) + response = supply_chain.node( + { + "components": ["pyproject.toml"], + "file_cache": {"pyproject.toml": ""}, + "local_file_cache": {"pyproject.toml": ""}, + "manifest": {}, + "component_metadata": [], + } + ) + + assert not any( + event["reason_code"] is LedgerReason.DEPENDENCY_PARSE_ERROR + for event in response["inspection_ledger"] + ) + + +@pytest.mark.parametrize("path", ["requirements/dev.txt", "requirements/base.txt"]) +def test_nested_requirements_manifests_retain_sc4_advisory_findings(monkeypatch, path): + seen = _capture_osv_packages(monkeypatch) + monkeypatch.setattr(supply_chain, "was_osv_reachable", lambda: True) + monkeypatch.setattr( + supply_chain.static_runner, + "run_static_patterns_with_ledger", + lambda _state, _modules: { + "findings": [], + "inspection_ledger": [], + "analyzer_status_events": [], + }, + ) + content = "pyyaml==5.3.1\n" + response = supply_chain.node( + { + "components": [path], + "file_cache": {path: content}, + "local_file_cache": {path: content}, + "manifest": {}, + "component_metadata": [], + } + ) + + assert ("pyyaml", "5.3.1") in seen["packages"] + assert any( + finding.rule_id == "SC4" and finding.file == path and "CVE-2020-14343" in finding.message + for finding in response["findings"] + ) + + +@pytest.mark.parametrize( + "path", + [ + "docs/pyproject.toml.md", + "docs/package.json.md", + "docs/uv.lock.md", + "docs/requirements/dev.txt.md", + ], +) +def test_manifest_documentation_is_not_analyzed_as_dependency_metadata(monkeypatch, path): + monkeypatch.setattr( + supply_chain.static_runner, + "run_static_patterns_with_ledger", + lambda _state, _modules: { + "findings": [], + "inspection_ledger": [], + "analyzer_status_events": [], + }, + ) + content = "This guide describes package.json and pyproject.toml files.\n" + response = supply_chain.node( + { + "components": [path], + "file_cache": {path: content}, + "local_file_cache": {path: content}, + "manifest": {}, + "component_metadata": [], + } + ) + + assert not any( + event["reason_code"] is LedgerReason.DEPENDENCY_PARSE_ERROR + for event in response["inspection_ledger"] + ) + assert not any(finding.rule_id == "SC4" for finding in response["findings"]) @pytest.mark.parametrize( diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index e05a26d49..4492abd08 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -555,6 +555,7 @@ def test_cli_scan_local_directory(tmp_path: Path) -> None: ("docs/pyproject.toml.md", "Documentation describing pyproject.toml fields.\n", False), ("docs/package.json.md", "Documentation describing package.json fields.\n", False), ("docs/uv.lock.md", "Documentation describing uv.lock fields.\n", False), + ("docs/requirements/dev.txt.md", "Documentation describing requirements files.\n", False), ], ) def test_cli_reports_malformed_dependency_manifest_completeness(