SwarmAudit / app /agents /dependency_agent.py
Pranoy Mukherjee
Update SwarmAudit Space demo
9237011
Raw
History Blame Contribute Delete
13.8 kB
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