diff --git a/gitgalaxy/galaxyscope.py b/gitgalaxy/galaxyscope.py index 30bccaa3..3e738964 100644 --- a/gitgalaxy/galaxyscope.py +++ b/gitgalaxy/galaxyscope.py @@ -970,14 +970,20 @@ def execute_pipeline(self, output_file: str = "galaxy.json"): # union, ecosystems MANIFEST_MAP doesn't track (composer.json, # requirements.txt) silently never reach the SBOM whenever any other # manifest is also present in the repo. - from gitgalaxy.security.manifest_parser import SUPPORTED_MANIFEST_FILENAMES, ManifestParser + from gitgalaxy.security.manifest_parser import ( + SUPPORTED_MANIFEST_FILENAMES, + SUPPORTED_MANIFEST_SUFFIXES, + ManifestParser, + ) guidestar_config = self.config.get("GUIDESTAR_CONFIG", {}) target_manifests = set(guidestar_config.get("MANIFEST_MAP", {}).keys()) | set(SUPPORTED_MANIFEST_FILENAMES) manifest_paths = [ str(self.root / rel_path) for rel_path in self.stem_map.values() - if Path(rel_path).name in target_manifests + # Suffix check covers per-project-named manifests (e.g. *.csproj) + # that can't live in the exact-filename SUPPORTED_MANIFEST_FILENAMES set. + if Path(rel_path).name in target_manifests or Path(rel_path).suffix in SUPPORTED_MANIFEST_SUFFIXES ] # 2. Build the global translation map diff --git a/gitgalaxy/recorders/sbom_recorder.py b/gitgalaxy/recorders/sbom_recorder.py index 99521d1e..91c3a29e 100644 --- a/gitgalaxy/recorders/sbom_recorder.py +++ b/gitgalaxy/recorders/sbom_recorder.py @@ -19,7 +19,11 @@ # UniversalManifestSlicer now lives in the canonical manifest module (PR A of # the dependency-audit overhaul). Re-imported here so existing consumers and # tests importing it from this module keep working unchanged. -from gitgalaxy.security.manifest_parser import SUPPORTED_MANIFEST_FILENAMES, UniversalManifestSlicer +from gitgalaxy.security.manifest_parser import ( + SUPPORTED_MANIFEST_FILENAMES, + SUPPORTED_MANIFEST_SUFFIXES, + UniversalManifestSlicer, +) # Import exclusively from the GitGalaxy Hub from gitgalaxy.security.security_lens import SecurityLens @@ -94,6 +98,10 @@ def generate_report( manifests_found = [ (target_path / m, target_path) for m in self._MANIFEST_NAMES if (target_path / m).exists() ] + # Suffix-matched manifests (e.g. *.csproj) can't be enumerated by exact + # name; glob for them at the root, matching the non-recursive scope above. + for suffix in SUPPORTED_MANIFEST_SUFFIXES: + manifests_found += [(p, target_path) for p in target_path.glob(f"*{suffix}")] if not manifests_found: self.logger.warning("SBOM: No supported manifests found. Outputting empty BOM.") diff --git a/gitgalaxy/security/manifest_parser.py b/gitgalaxy/security/manifest_parser.py index 83f40e12..2e6d16ef 100644 --- a/gitgalaxy/security/manifest_parser.py +++ b/gitgalaxy/security/manifest_parser.py @@ -64,6 +64,12 @@ def build_resolution_map(self, manifest_paths: list) -> dict: self._parse_requirements_txt(manifest_path, local_map) elif filename in ["pip.conf", ".pypirc", "pip.ini"]: self._parse_pip_conf(manifest_path, local_map) + elif filename == "pyproject.toml": + self._parse_pyproject_toml(manifest_path, local_map) + elif filename == "yarn.lock": + self._parse_yarn_lock(manifest_path, local_map) + elif filename in ("build.gradle", "build.gradle.kts"): + self._parse_gradle(manifest_path, local_map) except Exception as e: self.logger.warning(f"Manifest Parser: Failed to parse structural definition {filename} - {e}") @@ -177,6 +183,95 @@ def _parse_pip_conf(self, filepath: Path, resolution_map: dict): # Prefix with INSECURE_REGISTRY so the Supply Chain Firewall can instantly block it resolution_map[f"INSECURE_REGISTRY_{filepath.name}"] = url + def _parse_pyproject_toml(self, filepath: Path, resolution_map: dict): + """ + Audits modern Python manifests (PEP 621 `[project] dependencies` arrays and + Poetry's `[tool.poetry.dependencies]` table) for the same Direct URI bypass + risk requirements.txt is already audited for. + """ + with open(filepath, encoding="utf-8") as f: + content = f.read() + + # PEP 621: dependencies = ["requests>=2.0", "mypkg @ git+https://evil.com/x.git"] + array_match = re.search(r"dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL) + if array_match: + for entry in re.findall(r'["\']([^"\']+)["\']', array_match.group(1)): + if " @ " not in entry: + continue + pkg_part, _, ref = entry.partition(" @ ") + ref = ref.strip() + if self.python_direct_uri_regex.match(ref): + pkg_name = re.split(r"[\[;\s]", pkg_part.strip())[0] + resolution_map[pkg_name] = ref + self.logger.warning(f"Manifest Parser: Flagged direct URI reference for '{pkg_name}' -> {ref}") + + # Poetry: requests = {git = "https://evil.com/x.git"} / {url = "..."} + poetry_block = re.search(r"\[tool\.poetry\.dependencies\](.*?)(?=\n\[|\Z)", content, re.DOTALL) + if poetry_block: + for line in poetry_block.group(1).splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + pkg_name, _, value = line.partition("=") + pkg_name = pkg_name.strip() + if pkg_name.lower() == "python": + continue + + git_match = re.search(r'git\s*=\s*["\']([^"\']+)["\']', value) + url_match = re.search(r'url\s*=\s*["\']([^"\']+)["\']', value) + if git_match: + ref = f"git+{git_match.group(1)}" + resolution_map[pkg_name] = ref + self.logger.warning(f"Manifest Parser: Flagged direct git reference for '{pkg_name}' -> {ref}") + elif url_match: + resolution_map[pkg_name] = url_match.group(1) + self.logger.warning( + f"Manifest Parser: Flagged direct URL reference for '{pkg_name}' -> {url_match.group(1)}" + ) + + def _parse_yarn_lock(self, filepath: Path, resolution_map: dict): + """ + Yarn's counterpart to _parse_package_lock: yarn.lock entries are separated by + blank lines, each headed by one or more quoted/unquoted "name@range" specs + followed by a `resolved "..."` URL. Flags resolutions outside Yarn's/npm's + standard registries the same way package-lock.json resolutions are. + """ + with open(filepath, encoding="utf-8") as f: + content = f.read() + + for block in content.split("\n\n"): + block = block.strip("\n") + if not block or block.startswith("#"): + continue + + header_match = re.match(r'^"?(@?[^@"\s]+)@', block) + resolved_match = re.search(r'resolved\s+"([^"]+)"', block) + if not header_match or not resolved_match: + continue + + pkg_name = header_match.group(1) + resolved_url = resolved_match.group(1) + if not resolved_url.startswith(("https://registry.yarnpkg.com/", "https://registry.npmjs.org/")): + resolution_map[pkg_name] = resolved_url + self.logger.info( + f"Manifest Parser: Flagged non-standard registry resolution for '{pkg_name}' -> {resolved_url}" + ) + + def _parse_gradle(self, filepath: Path, resolution_map: dict): + """ + Audits Gradle build scripts (Groovy or Kotlin DSL) for insecure `http://` + repository declarations -- the Maven/Gradle equivalent of pip.conf's + insecure index-url check. + """ + with open(filepath, encoding="utf-8") as f: + content = f.read() + + insecure_urls = re.findall(r'url\s*[=(]?\s*["\']?(http://[^"\'\s)]+)', content) + for idx, url in enumerate(insecure_urls): + self.logger.warning(f"🚨 Manifest Parser: INSECURE GRADLE REPOSITORY DETECTED -> {url}") + key = f"INSECURE_REGISTRY_{filepath.name}" if idx == 0 else f"INSECURE_REGISTRY_{filepath.name}_{idx}" + resolution_map[key] = url + # NEW: # Filenames UniversalManifestSlicer.slice_manifest() below knows how to parse @@ -194,8 +289,35 @@ def _parse_pip_conf(self, filepath: Path, resolution_map: dict): "go.mod", "Gemfile", "pom.xml", + # Modern Python (issue #702) + "pyproject.toml", + "poetry.lock", + "Pipfile", + # .NET / NuGet + "packages.config", + # C/C++ + "conanfile.txt", + "vcpkg.json", + # Java/Kotlin/Android (Gradle) + "build.gradle", + "build.gradle.kts", + # Mobile (iOS/macOS) + "Podfile", + "Package.swift", + # Dart/Flutter + "pubspec.yaml", + # JS/TS alternative lockfiles + "yarn.lock", + "pnpm-lock.yaml", ) +# Suffix-matched manifests, for filenames that vary per-project (e.g. a repo's +# .NET solution can have any number of arbitrarily-named *.csproj files). Kept +# separate from SUPPORTED_MANIFEST_FILENAMES, which is an exact-name set -- +# callers that discover manifests via a filename lookup (galaxyscope's Phase +# 10 stem_map filter, SbomRecorder's standalone fallback) must check both. +SUPPORTED_MANIFEST_SUFFIXES = (".csproj",) + class UniversalManifestSlicer: """ @@ -302,6 +424,176 @@ def slice_manifest(manifest_path: Path) -> tuple[str, dict[str, str]]: for artifact, version in deps_raw: deps[artifact] = version if version else "latest" + elif filename == "pyproject.toml": + ecosystem = "pypi" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + # PEP 621: [project] dependencies = ["requests>=2.0", ...] + array_match = re.search(r"dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL) + if array_match: + for entry in re.findall(r'["\']([^"\']+)["\']', array_match.group(1)): + name_match = re.match(r"^[A-Za-z0-9_.\-]+", entry.strip()) + if name_match: + deps[name_match.group(0)] = "latest" # Simplified version extraction + # Poetry: [tool.poetry.dependencies] + poetry_block = re.search(r"\[tool\.poetry\.dependencies\](.*?)(?=\n\[|\Z)", content, re.DOTALL) + if poetry_block: + for line in poetry_block.group(1).splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + pkg_name, _, value = line.partition("=") + pkg_name = pkg_name.strip() + if pkg_name.lower() == "python": + continue + version_match = re.search(r'"([^"]+)"', value) + deps[pkg_name] = version_match.group(1) if version_match else "latest" + + elif filename == "poetry.lock": + ecosystem = "pypi" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + for block in re.findall(r"\[\[package\]\](.*?)(?=\n\[\[package\]\]|\Z)", content, re.DOTALL): + name_match = re.search(r'name\s*=\s*"([^"]+)"', block) + version_match = re.search(r'version\s*=\s*"([^"]+)"', block) + if name_match: + deps[name_match.group(1)] = version_match.group(1) if version_match else "latest" + + elif filename == "Pipfile": + ecosystem = "pypi" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + for block in re.findall(r"\[(?:dev-)?packages\](.*?)(?=\n\[|\Z)", content, re.DOTALL): + for line in block.splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + pkg_name, _, value = line.partition("=") + version_match = re.search(r'"([^"]+)"', value) + version = version_match.group(1) if version_match else "latest" + deps[pkg_name.strip()] = "latest" if version == "*" else version + + elif filename == "packages.config": + ecosystem = "nuget" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + for pkg_id, version in re.findall(r' 5.4' + if line.startswith("pod "): + parts = line[len("pod ") :].split(",") + pkg_name = parts[0].strip(" '\"") + version = parts[1].strip(" '\"") if len(parts) > 1 else "latest" + deps[pkg_name] = version + + elif filename == "Package.swift": + ecosystem = "swiftpm" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + # Capture each .package(...) call's full argument list (tolerating one + # level of nesting, e.g. `.upToNextMajor(from: "1.0.0")`) so `url:` and + # `from:` can be located independently regardless of argument order. + for call_args in re.findall(r"\.package\(((?:[^()]|\([^()]*\))*)\)", content): + url_match = re.search(r'url:\s*"([^"]+)"', call_args) + if not url_match: + continue + version_match = re.search(r'from:\s*"([^"]+)"', call_args) + pkg_name = url_match.group(1).rstrip("/").rsplit("/", 1)[-1] + if pkg_name.endswith(".git"): + pkg_name = pkg_name[:-4] + deps[pkg_name] = version_match.group(1) if version_match else "latest" + + elif filename == "pubspec.yaml": + ecosystem = "pub" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + dep_block = re.search(r"^dependencies:\n((?:[ \t]+.*\n?)*)", content, re.MULTILINE) + if dep_block: + for line in dep_block.group(1).splitlines(): + entry_match = re.match(r"^ ([A-Za-z0-9_]+):\s*(.*)$", line) + if entry_match: + pkg_name, version = entry_match.group(1), entry_match.group(2).strip() + deps[pkg_name] = version if version and not version.startswith("{") else "latest" + + elif filename == "yarn.lock": + ecosystem = "npm" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + for block in content.split("\n\n"): + block = block.strip("\n") + header_match = re.match(r'^"?(@?[^@"\s]+)@', block) + version_match = re.search(r'version\s+"([^"]+)"', block) + if header_match and version_match: + deps[header_match.group(1)] = version_match.group(1) + + elif filename == "pnpm-lock.yaml": + ecosystem = "npm" + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + # pnpm lockfile v6+: top-level `dependencies:`/`devDependencies:` blocks, + # each entry ` pkg-name:\n version: 1.2.3` (a `specifier:` sibling + # line, ignored here, carries the declared range instead of the resolution). + for section in re.findall( + r"^(?:dependencies|devDependencies):\n((?:[ \t]+.*\n?)*)", content, re.MULTILINE + ): + current_pkg = None + for line in section.splitlines(): + pkg_match = re.match(r"^ (\S+):\s*$", line) + version_match = re.match(r"^\s+version:\s*([^\s(]+)", line) + if pkg_match: + current_pkg = pkg_match.group(1) + elif version_match and current_pkg: + deps[current_pkg] = version_match.group(1) + current_pkg = None + except Exception as exc: logging.getLogger("manifest_parser").warning( "Failed to parse manifest '%s' (%s): %s", @@ -394,4 +686,62 @@ def locate_physical_package( return file return None + elif ecosystem == "nuget": + # Modern SDK-style projects restore into project-local `packages/` + # or the global per-user cache; check both, local first. + local = target_path / "packages" / pkg_name + if local.exists(): + return local + global_cache = Path.home() / ".nuget" / "packages" / pkg_name.lower() + return global_cache if global_cache.exists() else None + + elif ecosystem == "conan": + # Conan 2.x's local cache keys packages by name/version/revision; + # loosely matched since the exact revision hash varies per build. + cache_root = Path.home() / ".conan2" / "p" + if cache_root.exists(): + for entry in cache_root.iterdir(): + if entry.name.lower().startswith(pkg_name.lower()): + return entry + return None + + elif ecosystem == "vcpkg": + # vcpkg installs into a project-local triplet-scoped tree. + target = target_path / "vcpkg_installed" + if target.exists(): + for root, dirs, _ in os.walk(target): + if pkg_name in dirs: + return Path(root) / pkg_name + return None + + elif ecosystem == "gradle": + # Gradle's per-user module cache, keyed by group:artifact:version. + cache_root = Path.home() / ".gradle" / "caches" / "modules-2" / "files-2.1" + if cache_root.exists(): + artifact = pkg_name.rsplit(":", 1)[-1] + for root, dirs, _ in os.walk(cache_root): + if artifact in dirs: + return Path(root) / artifact + return None + + elif ecosystem == "cocoapods": + target = target_path / "Pods" / pkg_name + return target if target.exists() else None + + elif ecosystem == "swiftpm": + for build_dir in (".build/checkouts", ".swiftpm/checkouts"): + target = target_path / build_dir / pkg_name + if target.exists(): + return target + return None + + elif ecosystem == "pub": + # pub's global per-user hosted-package cache, versioned in the dirname. + cache_root = Path.home() / ".pub-cache" / "hosted" / "pub.dev" + if cache_root.exists(): + for entry in cache_root.iterdir(): + if entry.name.startswith(f"{pkg_name}-"): + return entry + return None + return None diff --git a/tests/core_engine/test_manifest_parser.py b/tests/core_engine/test_manifest_parser.py index aaca34d7..122caf6c 100644 --- a/tests/core_engine/test_manifest_parser.py +++ b/tests/core_engine/test_manifest_parser.py @@ -265,6 +265,84 @@ def test_pip_conf_repository_keyword_with_equals_in_value(parser, tmp_path): assert resolution_map["INSECURE_REGISTRY_.pypirc"] == "http://example.com/simple?token=abc123" +# ============================================================================== +# 4b. Issue #702 -- Expanded Security Auditing (pyproject.toml, yarn.lock, Gradle) +# ============================================================================== +def test_pyproject_toml_pep621_direct_uri_reference(parser, tmp_path): + """Verifies PEP 621 `dependencies = [...]` entries with a direct `@ git+...`/URL + reference (which bypass PyPI registry verification) are flagged, same as requirements.txt.""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text( + '[project]\nname = "test"\ndependencies = [\n' + ' "requests>=2.0",\n' + ' "evil-pkg @ git+https://github.com/hacker/malware.git",\n' + "]\n" + ) + + resolution_map = parser.build_resolution_map([str(pyproject_file)]) + + assert "requests" not in resolution_map, "Standard packages shouldn't be added to the resolution map" + assert resolution_map["evil-pkg"] == "git+https://github.com/hacker/malware.git" + + +def test_pyproject_toml_poetry_direct_git_reference(parser, tmp_path): + """Verifies Poetry-style `[tool.poetry.dependencies]` table entries with an inline + git/url table are flagged, and the `python` version constraint entry is ignored.""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text( + '[tool.poetry.dependencies]\npython = "^3.9"\nnumpy = "^1.21"\n' + 'evil-pkg = {git = "https://github.com/hacker/malware.git"}\n' + ) + + resolution_map = parser.build_resolution_map([str(pyproject_file)]) + + assert "python" not in resolution_map + assert "numpy" not in resolution_map, "Standard packages shouldn't be added to the resolution map" + assert resolution_map["evil-pkg"] == "git+https://github.com/hacker/malware.git" + + +def test_yarn_lock_registry_spoofing(parser, tmp_path): + """Verifies yarn.lock's counterpart to package-lock.json's registry-spoofing check: + resolutions outside the standard Yarn/npm registries must be intercepted.""" + yarn_lock = tmp_path / "yarn.lock" + yarn_lock.write_text( + 'ansi-styles@^3.2.1:\n version "3.2.1"\n' + ' resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#cafebabe"\n\n' + '"@hacker-scope/evil-pkg@^1.0.0":\n version "1.0.0"\n' + ' resolved "https://evil-registry.com/evil-pkg-1.0.0.tgz"\n' + ) + + resolution_map = parser.build_resolution_map([str(yarn_lock)]) + + assert "ansi-styles" not in resolution_map, "Standard registry resolutions should be trusted" + assert resolution_map["@hacker-scope/evil-pkg"] == "https://evil-registry.com/evil-pkg-1.0.0.tgz" + + +def test_gradle_insecure_repository(parser, tmp_path): + """Verifies build.gradle repository blocks using an insecure `http://` URL are flagged, + the Maven/Gradle equivalent of pip.conf's insecure index-url check.""" + gradle_file = tmp_path / "build.gradle" + gradle_file.write_text( + "repositories {\n mavenCentral()\n maven {\n url 'http://insecure-mirror.example.com/repo'\n" + " }\n}\n" + ) + + resolution_map = parser.build_resolution_map([str(gradle_file)]) + + assert "INSECURE_REGISTRY_build.gradle" in resolution_map + assert resolution_map["INSECURE_REGISTRY_build.gradle"] == "http://insecure-mirror.example.com/repo" + + +def test_gradle_trusted_repository(parser, tmp_path): + """Ensures a Gradle build script using only HTTPS repositories does not false-positive.""" + gradle_file = tmp_path / "build.gradle" + gradle_file.write_text("repositories {\n mavenCentral()\n google()\n}\n") + + resolution_map = parser.build_resolution_map([str(gradle_file)]) + + assert resolution_map == {}, "Trusted HTTPS-only repositories falsely flagged as insecure!" + + # ============================================================================== # 5. Global Monorepo Tests # ============================================================================== @@ -298,14 +376,14 @@ def test_unsupported_manifest_bypass(parser, tmp_path): def test_manifest_parser_scope_is_npm_and_pypi_only(parser, tmp_path): """ Documents current scope, not a bug: ManifestParser.build_resolution_map - only builds an alias/registry-spoofing map for npm and PyPI-family files. - It does NOT recognize composer.json, Cargo.toml, Gemfile, or pom.xml -- - even though UniversalManifestSlicer (this module's OTHER class, used for - the SBOM) parses all of those. Since galaxyscope's Phase 10 now feeds the - SAME manifest_paths list to both consumers, these filenames silently - no-op here. Locking this in so a future contributor extending - SUPPORTED_MANIFEST_FILENAMES doesn't assume ManifestParser gained - matching coverage for free. + only builds an alias/registry-spoofing map for npm-family, PyPI-family, + and (since issue #702) Gradle files. It does NOT recognize composer.json, + Cargo.toml, Gemfile, or pom.xml -- even though UniversalManifestSlicer + (this module's OTHER class, used for the SBOM) parses all of those. Since + galaxyscope's Phase 10 now feeds the SAME manifest_paths list to both + consumers, these filenames silently no-op here. Locking this in so a + future contributor extending SUPPORTED_MANIFEST_FILENAMES doesn't assume + ManifestParser gained matching coverage for free. """ cargo_file = tmp_path / "Cargo.toml" cargo_file.write_text('[dependencies]\nserde = "1.0"') diff --git a/tests/ruff_audit_baseline.json b/tests/ruff_audit_baseline.json index 698cdf60..e4941ecd 100644 --- a/tests/ruff_audit_baseline.json +++ b/tests/ruff_audit_baseline.json @@ -16,13 +16,13 @@ "gitgalaxy/core/detector.py:886: PERF401": "Use `list.extend` to create a transformed list", "gitgalaxy/core/guidestar_lens.py:139: C401": "Unnecessary generator (rewrite as a set comprehension)", "gitgalaxy/core/network_risk_sensor.py:274: PERF401": "Use a list comprehension to create a transformed list", - "gitgalaxy/galaxyscope.py:1035: SIM102": "Use a single `if` statement instead of nested `if` statements", - "gitgalaxy/galaxyscope.py:1126: SIM118": "Use `key in dict` instead of `key in dict.keys()`", - "gitgalaxy/galaxyscope.py:1137: SIM118": "Use `key in dict` instead of `key in dict.keys()`", - "gitgalaxy/galaxyscope.py:1154: SIM102": "Use a single `if` statement instead of nested `if` statements", - "gitgalaxy/galaxyscope.py:1158: SIM102": "Use a single `if` statement instead of nested `if` statements", - "gitgalaxy/galaxyscope.py:2539: SIM118": "Use `key in dict` instead of `key in dict.keys()`", - "gitgalaxy/galaxyscope.py:2562: SIM118": "Use `key in dict` instead of `key in dict.keys()`", + "gitgalaxy/galaxyscope.py:1041: SIM102": "Use a single `if` statement instead of nested `if` statements", + "gitgalaxy/galaxyscope.py:1132: SIM118": "Use `key in dict` instead of `key in dict.keys()`", + "gitgalaxy/galaxyscope.py:1143: SIM118": "Use `key in dict` instead of `key in dict.keys()`", + "gitgalaxy/galaxyscope.py:1160: SIM102": "Use a single `if` statement instead of nested `if` statements", + "gitgalaxy/galaxyscope.py:1164: SIM102": "Use a single `if` statement instead of nested `if` statements", + "gitgalaxy/galaxyscope.py:2545: SIM118": "Use `key in dict` instead of `key in dict.keys()`", + "gitgalaxy/galaxyscope.py:2568: SIM118": "Use `key in dict` instead of `key in dict.keys()`", "gitgalaxy/licensing.py:82: DTZ007": "Naive datetime constructed using `datetime.datetime.strptime()` without %z", "gitgalaxy/licensing.py:83: DTZ005": "`datetime.datetime.now()` called without a `tz` argument", "gitgalaxy/metrics/chronometer.py:299: SIM118": "Use `key in dict` instead of `key in dict.keys()`", @@ -81,7 +81,7 @@ "gitgalaxy/recorders/record_keeper.py:795: W291": "Trailing whitespace", "gitgalaxy/recorders/record_keeper.py:796: W291": "Trailing whitespace", "gitgalaxy/recorders/record_keeper.py:827: W291": "Trailing whitespace", - "gitgalaxy/recorders/sbom_recorder.py:213: PERF401": "Use `list.extend` to create a transformed list", + "gitgalaxy/recorders/sbom_recorder.py:221: PERF401": "Use `list.extend` to create a transformed list", "gitgalaxy/security/security_auditor.py:359: RUF046": "Value being cast to `int` is already an integer", "gitgalaxy/security/security_auditor.py:424: PERF203": "`try`-`except` within a loop incurs performance overhead", "gitgalaxy/security/security_lens.py:357: SIM102": "Use a single `if` statement instead of nested `if` statements", diff --git a/tests/tools_recorders/test_sbom_generator.py b/tests/tools_recorders/test_sbom_generator.py index 60ba9786..05c5d5d2 100644 --- a/tests/tools_recorders/test_sbom_generator.py +++ b/tests/tools_recorders/test_sbom_generator.py @@ -5,13 +5,15 @@ from unittest.mock import patch from gitgalaxy.recorders.sbom_recorder import UniversalManifestSlicer, SbomRecorder +from gitgalaxy.security.manifest_parser import SUPPORTED_MANIFEST_SUFFIXES # ============================================================================== # TEST 1: The Multi-Ecosystem Slicer Guard (Full Ecosystem Matrix) # ============================================================================== def test_universal_manifest_slicer_all_ecosystems(tmp_path): - """Proves regex and parsing logic flawlessly extracts dependencies across all 7 supported ecosystems.""" + """Proves regex and parsing logic flawlessly extracts dependencies across the original 7 supported ecosystems. + See test_universal_manifest_slicer_expanded_ecosystems_702 for the ecosystems added by issue #702.""" slicer = UniversalManifestSlicer() # 1. NPM @@ -131,6 +133,172 @@ def test_locate_physical_package(tmp_path): assert slicer.locate_physical_package(tmp_path, "pkg", "alien_eco") is None +# ============================================================================== +# TEST 2b: Issue #702 -- Expanded Ecosystem Coverage +# ============================================================================== +def test_universal_manifest_slicer_expanded_ecosystems_702(tmp_path): + """Proves slice_manifest() extracts dependencies from every ecosystem added by issue #702.""" + slicer = UniversalManifestSlicer() + + # Modern Python: pyproject.toml (PEP 621 + Poetry) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "test"\ndependencies = [\n "requests>=2.0",\n "flask",\n]\n\n' + '[tool.poetry.dependencies]\npython = "^3.9"\nnumpy = "^1.21"\n', + encoding="utf-8", + ) + eco, deps = slicer.slice_manifest(pyproject) + assert eco == "pypi" + assert deps == {"requests": "latest", "flask": "latest", "numpy": "^1.21"} + + # Modern Python: poetry.lock + poetry_lock = tmp_path / "poetry.lock" + poetry_lock.write_text( + '[[package]]\nname = "requests"\nversion = "2.26.0"\n\n[[package]]\nname = "flask"\nversion = "2.0.1"\n', + encoding="utf-8", + ) + assert slicer.slice_manifest(poetry_lock) == ("pypi", {"requests": "2.26.0", "flask": "2.0.1"}) + + # Modern Python: Pipfile + pipfile = tmp_path / "Pipfile" + pipfile.write_text( + '[packages]\nrequests = "*"\nflask = ">=1.0"\n\n[dev-packages]\npytest = "*"\n', encoding="utf-8" + ) + assert slicer.slice_manifest(pipfile) == ( + "pypi", + {"requests": "latest", "flask": ">=1.0", "pytest": "latest"}, + ) + + # .NET: packages.config + packages_config = tmp_path / "packages.config" + packages_config.write_text( + '\n \n' + ' \n\n', + encoding="utf-8", + ) + assert slicer.slice_manifest(packages_config) == ( + "nuget", + {"Newtonsoft.Json": "12.0.3", "NUnit": "latest"}, + ) + + # .NET: *.csproj (PackageReference), suffix-matched rather than exact filename + csproj = tmp_path / "MyApp.csproj" + csproj.write_text( + '\n \n' + ' \n' + ' \n \n\n', + encoding="utf-8", + ) + assert slicer.slice_manifest(csproj) == ("nuget", {"Serilog": "2.10.0", "AutoMapper": "latest"}) + + # C/C++: conanfile.txt + conanfile = tmp_path / "conanfile.txt" + conanfile.write_text("[requires]\nboost/1.75.0\nzlib/1.2.11\n\n[generators]\ncmake\n", encoding="utf-8") + assert slicer.slice_manifest(conanfile) == ("conan", {"boost": "1.75.0", "zlib": "1.2.11"}) + + # C/C++: vcpkg.json + vcpkg_json = tmp_path / "vcpkg.json" + vcpkg_json.write_text( + json.dumps({"name": "myapp", "dependencies": ["fmt", {"name": "curl", "features": ["ssl"]}]}), + encoding="utf-8", + ) + assert slicer.slice_manifest(vcpkg_json) == ("vcpkg", {"fmt": "latest", "curl": "latest"}) + + # Java/Kotlin/Android: build.gradle + gradle = tmp_path / "build.gradle" + gradle.write_text( + "dependencies {\n implementation 'com.google.guava:guava:30.1-jre'\n" + ' testImplementation("junit:junit:4.13")\n}\n', + encoding="utf-8", + ) + assert slicer.slice_manifest(gradle) == ( + "gradle", + {"com.google.guava:guava": "30.1-jre", "junit:junit": "4.13"}, + ) + + # Mobile: Podfile (CocoaPods) + podfile = tmp_path / "Podfile" + podfile.write_text("platform :ios, '13.0'\npod 'Alamofire', '~> 5.4'\npod 'SDWebImage'\n", encoding="utf-8") + assert slicer.slice_manifest(podfile) == ("cocoapods", {"Alamofire": "~> 5.4", "SDWebImage": "latest"}) + + # Mobile: Package.swift (Swift Package Manager) + package_swift = tmp_path / "Package.swift" + package_swift.write_text( + 'let package = Package(\n name: "MyLib",\n dependencies: [\n' + ' .package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"),\n' + ' .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"),\n ]\n)\n', + encoding="utf-8", + ) + assert slicer.slice_manifest(package_swift) == ( + "swiftpm", + {"swift-log": "1.4.0", "swift-algorithms": "1.0.0"}, + ) + + # Dart/Flutter: pubspec.yaml + pubspec = tmp_path / "pubspec.yaml" + pubspec.write_text( + "name: myapp\ndependencies:\n flutter:\n sdk: flutter\n http: ^0.13.3\n provider: ^6.0.0\n\n" + "dev_dependencies:\n test: ^1.16.0\n", + encoding="utf-8", + ) + eco, deps = slicer.slice_manifest(pubspec) + assert eco == "pub" + assert deps == {"flutter": "latest", "http": "^0.13.3", "provider": "^6.0.0"} + assert "test" not in deps, "dev_dependencies should not be conflated with the top-level dependencies block" + + # JS/TS alternative lockfile: yarn.lock + yarn_lock = tmp_path / "yarn.lock" + yarn_lock.write_text( + '"@babel/core@^7.0.0":\n version "7.12.3"\n' + ' resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.3.tgz#deadbeef"\n\n' + 'ansi-styles@^3.2.1:\n version "3.2.1"\n' + ' resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#cafebabe"\n', + encoding="utf-8", + ) + assert slicer.slice_manifest(yarn_lock) == ( + "npm", + {"@babel/core": "7.12.3", "ansi-styles": "3.2.1"}, + ) + + # JS/TS alternative lockfile: pnpm-lock.yaml + pnpm_lock = tmp_path / "pnpm-lock.yaml" + pnpm_lock.write_text( + "lockfileVersion: '6.0'\n\ndependencies:\n express:\n specifier: ^4.18.0\n version: 4.18.2\n" + " lodash:\n specifier: ^4.17.21\n version: 4.17.21\n\n" + "devDependencies:\n jest:\n specifier: ^29.0.0\n version: 29.0.3\n", + encoding="utf-8", + ) + assert slicer.slice_manifest(pnpm_lock) == ( + "npm", + {"express": "4.18.2", "lodash": "4.17.21", "jest": "29.0.3"}, + ) + + +def test_locate_physical_package_expanded_ecosystems_702(tmp_path): + """Proves locate_physical_package() finds packages for every ecosystem added by issue #702.""" + slicer = UniversalManifestSlicer() + + # NuGet: project-local packages/ dir + (tmp_path / "packages" / "Serilog").mkdir(parents=True) + assert slicer.locate_physical_package(tmp_path, "Serilog", "nuget") is not None + assert slicer.locate_physical_package(tmp_path, "ghost", "nuget") is None + + # Conan: project-local vcpkg_installed/ + (tmp_path / "vcpkg_installed" / "x64-linux" / "fmt").mkdir(parents=True) + assert slicer.locate_physical_package(tmp_path, "fmt", "vcpkg") is not None + assert slicer.locate_physical_package(tmp_path, "ghost", "vcpkg") is None + + # CocoaPods: Pods/ + (tmp_path / "Pods" / "Alamofire").mkdir(parents=True) + assert slicer.locate_physical_package(tmp_path, "Alamofire", "cocoapods") is not None + assert slicer.locate_physical_package(tmp_path, "ghost", "cocoapods") is None + + # Swift Package Manager: .build/checkouts/ + (tmp_path / ".build" / "checkouts" / "swift-log").mkdir(parents=True) + assert slicer.locate_physical_package(tmp_path, "swift-log", "swiftpm") is not None + assert slicer.locate_physical_package(tmp_path, "ghost", "swiftpm") is None + + # ============================================================================== # TEST 3: Graceful Fallbacks (Missing Targets & Empty Voids) # ============================================================================== @@ -275,6 +443,20 @@ def test_manifest_names_match_slicer_support(tmp_path): "go.mod": "golang", "Gemfile": "rubygems", "pom.xml": "maven", + # Issue #702 additions + "pyproject.toml": "pypi", + "poetry.lock": "pypi", + "Pipfile": "pypi", + "packages.config": "nuget", + "conanfile.txt": "conan", + "vcpkg.json": "vcpkg", + "build.gradle": "gradle", + "build.gradle.kts": "gradle", + "Podfile": "cocoapods", + "Package.swift": "swiftpm", + "pubspec.yaml": "pub", + "yarn.lock": "npm", + "pnpm-lock.yaml": "npm", } assert set(SbomRecorder._MANIFEST_NAMES) == set(expected_ecosystems), ( @@ -292,6 +474,26 @@ def test_manifest_names_match_slicer_support(tmp_path): ) +def test_manifest_suffixes_match_slicer_support(tmp_path): + """ + Suffix-based counterpart to test_manifest_names_match_slicer_support: + SUPPORTED_MANIFEST_SUFFIXES (issue #702's *.csproj addition) exists + because some manifests -- unlike every other entry in + SUPPORTED_MANIFEST_FILENAMES -- are named arbitrarily per-project rather + than with one fixed filename, so they can't live in that exact-name set. + """ + slicer = UniversalManifestSlicer() + assert SUPPORTED_MANIFEST_SUFFIXES == (".csproj",) + + f = tmp_path / "SomeArbitraryProjectName.csproj" + f.write_text("") + ecosystem, _ = slicer.slice_manifest(f) + assert ecosystem == "nuget", ( + f"*.csproj is in SUPPORTED_MANIFEST_SUFFIXES but slice_manifest identified it as " + f"'{ecosystem}' instead of 'nuget' -- the two are out of sync!" + ) + + def test_locate_physical_package_hoisted_dependency(tmp_path): """Regression: npm/yarn/pnpm workspaces hoist shared deps to the workspace root instead of duplicating them per sub-package. Without