| import json |
| import re |
| import tomllib |
| from dataclasses import dataclass |
| from typing import Any |
|
|
| import httpx |
|
|
| from app.config import Settings |
| from app.schemas import AgentOutput, CodeChunk, Finding, Severity |
|
|
|
|
| @dataclass(frozen=True) |
| class Dependency: |
| name: str |
| version: str | None |
| ecosystem: str |
| manifest_path: str |
| line_number: int |
| source: str |
|
|
|
|
| class DependencyAgent: |
| name = "Dependency Agent" |
|
|
| def __init__(self, settings: Settings): |
| self.settings = settings |
|
|
| async def analyze(self, chunks: list[CodeChunk]) -> AgentOutput: |
| dependencies = self._parse_dependencies(chunks) |
| findings: list[Finding] = [] |
| cves: list[dict[str, Any]] = [] |
| warnings: list[str] = [] |
|
|
| if self.settings.enable_dependency_cve_lookup and dependencies: |
| cves, warnings = await self._lookup_cves(dependencies) |
| findings.extend(self._cve_findings(cves)) |
|
|
| return AgentOutput( |
| agent_name=self.name, |
| findings=findings, |
| metadata={ |
| "mode": "manifest-parse+optional-osv", |
| "dependency_count": len(dependencies), |
| "manifests": sorted({dependency.manifest_path for dependency in dependencies}), |
| "dependency_cves": cves, |
| "warnings": warnings, |
| }, |
| ) |
|
|
| def _parse_dependencies(self, chunks: list[CodeChunk]) -> list[Dependency]: |
| dependencies: list[Dependency] = [] |
| seen: set[tuple[str, str, str, str | None]] = set() |
|
|
| for chunk in chunks: |
| parsed = self._parse_chunk(chunk) |
| for dependency in parsed: |
| key = ( |
| dependency.ecosystem, |
| dependency.name.lower(), |
| dependency.manifest_path, |
| dependency.version, |
| ) |
| if key in seen: |
| continue |
| seen.add(key) |
| dependencies.append(dependency) |
|
|
| return dependencies |
|
|
| def _parse_chunk(self, chunk: CodeChunk) -> list[Dependency]: |
| path = chunk.file_path.lower() |
| if path.endswith("requirements.txt"): |
| return self._parse_requirements(chunk) |
| if path.endswith("package.json"): |
| return self._parse_package_json(chunk) |
| if path.endswith("pyproject.toml"): |
| return self._parse_pyproject(chunk) |
| if path.endswith("go.mod"): |
| return self._parse_go_mod(chunk) |
| if path.endswith("cargo.toml"): |
| return self._parse_cargo_toml(chunk) |
| return [] |
|
|
| def _parse_requirements(self, chunk: CodeChunk) -> list[Dependency]: |
| dependencies: list[Dependency] = [] |
| for offset, raw_line in enumerate(chunk.content.splitlines()): |
| line = raw_line.split("#", 1)[0].strip() |
| if not line or line.startswith(("-", "git+", "http://", "https://")): |
| continue |
| match = re.match(r"([A-Za-z0-9_.-]+)\s*(?:\[.*?\])?\s*(==|~=|>=|<=|>|<)?\s*([A-Za-z0-9_.*!+-][A-Za-z0-9_.*!+-]*)?", line) |
| if not match: |
| continue |
| name = match.group(1) |
| version = self._clean_version(match.group(3)) |
| dependencies.append( |
| Dependency( |
| name=name, |
| version=version, |
| ecosystem="PyPI", |
| manifest_path=chunk.file_path, |
| line_number=chunk.line_start + offset, |
| source=line, |
| ) |
| ) |
| return dependencies |
|
|
| def _parse_package_json(self, chunk: CodeChunk) -> list[Dependency]: |
| try: |
| data = json.loads(chunk.content) |
| except json.JSONDecodeError: |
| return [] |
|
|
| dependencies: list[Dependency] = [] |
| for section in ("dependencies", "devDependencies", "optionalDependencies"): |
| section_dependencies = data.get(section, {}) |
| if not isinstance(section_dependencies, dict): |
| continue |
| for name, raw_version in section_dependencies.items(): |
| dependencies.append( |
| Dependency( |
| name=name, |
| version=self._clean_version(str(raw_version)), |
| ecosystem="npm", |
| manifest_path=chunk.file_path, |
| line_number=self._line_for_text(chunk, f'"{name}"'), |
| source=section, |
| ) |
| ) |
| return dependencies |
|
|
| def _parse_pyproject(self, chunk: CodeChunk) -> list[Dependency]: |
| try: |
| data = tomllib.loads(chunk.content) |
| except tomllib.TOMLDecodeError: |
| return [] |
|
|
| dependencies: list[Dependency] = [] |
| project_dependencies = data.get("project", {}).get("dependencies", []) |
| if isinstance(project_dependencies, list): |
| for value in project_dependencies: |
| dependency = self._python_dependency_from_string(str(value), chunk) |
| if dependency: |
| dependencies.append(dependency) |
|
|
| poetry_dependencies = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) |
| if isinstance(poetry_dependencies, dict): |
| for name, value in poetry_dependencies.items(): |
| if name.lower() == "python": |
| continue |
| dependencies.append( |
| Dependency( |
| name=name, |
| version=self._clean_version(str(value)), |
| ecosystem="PyPI", |
| manifest_path=chunk.file_path, |
| line_number=self._line_for_text(chunk, name), |
| source="tool.poetry.dependencies", |
| ) |
| ) |
| return dependencies |
|
|
| def _parse_go_mod(self, chunk: CodeChunk) -> list[Dependency]: |
| dependencies: list[Dependency] = [] |
| in_require_block = False |
| for offset, raw_line in enumerate(chunk.content.splitlines()): |
| line = raw_line.strip() |
| if line.startswith("require ("): |
| in_require_block = True |
| continue |
| if in_require_block and line == ")": |
| in_require_block = False |
| continue |
| if line.startswith("require "): |
| line = line.removeprefix("require ").strip() |
| elif not in_require_block: |
| continue |
| parts = line.split() |
| if len(parts) < 2: |
| continue |
| dependencies.append( |
| Dependency( |
| name=parts[0], |
| version=self._clean_version(parts[1]), |
| ecosystem="Go", |
| manifest_path=chunk.file_path, |
| line_number=chunk.line_start + offset, |
| source=line, |
| ) |
| ) |
| return dependencies |
|
|
| def _parse_cargo_toml(self, chunk: CodeChunk) -> list[Dependency]: |
| try: |
| data = tomllib.loads(chunk.content) |
| except tomllib.TOMLDecodeError: |
| return [] |
|
|
| dependencies: list[Dependency] = [] |
| for section in ("dependencies", "dev-dependencies", "build-dependencies"): |
| section_dependencies = data.get(section, {}) |
| if not isinstance(section_dependencies, dict): |
| continue |
| for name, value in section_dependencies.items(): |
| version = value.get("version") if isinstance(value, dict) else str(value) |
| dependencies.append( |
| Dependency( |
| name=name, |
| version=self._clean_version(str(version)), |
| ecosystem="crates.io", |
| manifest_path=chunk.file_path, |
| line_number=self._line_for_text(chunk, name), |
| source=section, |
| ) |
| ) |
| return dependencies |
|
|
| def _python_dependency_from_string(self, value: str, chunk: CodeChunk) -> Dependency | None: |
| match = re.match(r"([A-Za-z0-9_.-]+)\s*(?:\[.*?\])?\s*(?:==|~=|>=|<=|>|<)?\s*([A-Za-z0-9_.*!+-]+)?", value) |
| if not match: |
| return None |
| return Dependency( |
| name=match.group(1), |
| version=self._clean_version(match.group(2)), |
| ecosystem="PyPI", |
| manifest_path=chunk.file_path, |
| line_number=self._line_for_text(chunk, match.group(1)), |
| source="project.dependencies", |
| ) |
|
|
| async def _lookup_cves(self, dependencies: list[Dependency]) -> tuple[list[dict[str, Any]], list[str]]: |
| query_dependencies = [dependency for dependency in dependencies if dependency.version] |
| if not query_dependencies: |
| return [], [] |
|
|
| queries = [ |
| { |
| "package": {"name": dependency.name, "ecosystem": dependency.ecosystem}, |
| "version": dependency.version, |
| } |
| for dependency in query_dependencies |
| ] |
| try: |
| async with httpx.AsyncClient(timeout=self.settings.dependency_osv_timeout_seconds) as client: |
| response = await client.post("https://api.osv.dev/v1/querybatch", json={"queries": queries}) |
| response.raise_for_status() |
| payload = response.json() |
| except Exception as exc: |
| return [], [f"Dependency CVE lookup failed gracefully: {exc}"] |
|
|
| cves: list[dict[str, Any]] = [] |
| results = payload.get("results", []) |
| for dependency, result in zip(query_dependencies, results, strict=False): |
| for vuln in result.get("vulns", []): |
| cves.append(self._cve_record(dependency, vuln)) |
| return cves, [] |
|
|
| def _cve_record(self, dependency: Dependency, vuln: dict[str, Any]) -> dict[str, Any]: |
| severity = self._severity_from_vuln(vuln) |
| return { |
| "id": vuln.get("id", "UNKNOWN"), |
| "package": dependency.name, |
| "version": dependency.version, |
| "ecosystem": dependency.ecosystem, |
| "severity": severity.value, |
| "summary": vuln.get("summary") or vuln.get("details", "Known vulnerability reported by OSV.dev."), |
| "manifest_path": dependency.manifest_path, |
| "line_number": dependency.line_number, |
| "fixed_version": self._fixed_version(vuln), |
| } |
|
|
| def _cve_findings(self, cves: list[dict[str, Any]]) -> list[Finding]: |
| findings: list[Finding] = [] |
| for cve in cves: |
| package = cve["package"] |
| version = cve.get("version") or "unknown" |
| cve_id = cve["id"] |
| fixed_version = cve.get("fixed_version") or "a non-vulnerable version" |
| findings.append( |
| Finding( |
| title=f"Vulnerable dependency: {package}", |
| severity=Severity(cve["severity"]), |
| file_path=cve["manifest_path"], |
| line_start=cve["line_number"], |
| line_end=cve["line_number"], |
| description=f"{package}@{version} is associated with {cve_id}: {cve['summary']}", |
| why_it_matters="Known vulnerable dependencies can expose the application to publicly documented exploits.", |
| suggested_fix=f"Upgrade {package} to {fixed_version} after checking compatibility and lockfile updates.", |
| agent_source=self.name, |
| category="dependency", |
| confidence=0.95, |
| ) |
| ) |
| return findings |
|
|
| def _severity_from_vuln(self, vuln: dict[str, Any]) -> Severity: |
| database_severity = str(vuln.get("database_specific", {}).get("severity", "")).upper() |
| if database_severity in Severity._value2member_map_: |
| return Severity(database_severity) |
|
|
| scores = [] |
| for severity in vuln.get("severity", []): |
| score = self._cvss_score(str(severity.get("score", ""))) |
| if score is not None: |
| scores.append(score) |
| max_score = max(scores, default=0.0) |
| if max_score >= 9: |
| return Severity.critical |
| if max_score >= 7: |
| return Severity.high |
| if max_score >= 4: |
| return Severity.medium |
| return Severity.low |
|
|
| def _cvss_score(self, score: str) -> float | None: |
| match = re.search(r"/AV:|CVSS:", score) |
| if match: |
| return None |
| try: |
| return float(score) |
| except ValueError: |
| return None |
|
|
| def _fixed_version(self, vuln: dict[str, Any]) -> str | None: |
| for affected in vuln.get("affected", []): |
| for range_data in affected.get("ranges", []): |
| for event in range_data.get("events", []): |
| fixed = event.get("fixed") |
| if fixed: |
| return fixed |
| return None |
|
|
| def _clean_version(self, value: str | None) -> str | None: |
| if not value: |
| return None |
| version = value.strip().strip('"').strip("'") |
| version = re.sub(r"^[\^~<>=!\s]+", "", version) |
| version = version.split(",", 1)[0].strip() |
| if not version or version == "*" or any(char in version for char in "{}"): |
| return None |
| return version |
|
|
| def _line_for_text(self, chunk: CodeChunk, text: str) -> int: |
| for offset, line in enumerate(chunk.content.splitlines()): |
| if text in line: |
| return chunk.line_start + offset |
| return chunk.line_start |
|
|