repoguard-tool 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
repoguard/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """RepoGuard - Open-Source Repository Health Analyzer.
2
+
3
+ Analyze. Detect. Improve.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __version__ = "0.1.0"
9
+ __app_name__ = "repoguard"
10
+
11
+ __all__ = ["__app_name__", "__version__"]
@@ -0,0 +1,28 @@
1
+ """Built-in analyzers for RepoGuard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from repoguard.analyzers.cicd import CiCdAnalyzer
6
+ from repoguard.analyzers.code_quality import CodeQualityAnalyzer
7
+ from repoguard.analyzers.dependencies import DependencyAnalyzer
8
+ from repoguard.analyzers.documentation import DocumentationAnalyzer
9
+ from repoguard.analyzers.security import SecurityAnalyzer
10
+ from repoguard.analyzers.testing import TestingAnalyzer
11
+ from repoguard.core.registry import registry
12
+
13
+ # Automatically register built-in analyzers
14
+ registry.register(DocumentationAnalyzer, overwrite=True)
15
+ registry.register(DependencyAnalyzer, overwrite=True)
16
+ registry.register(TestingAnalyzer, overwrite=True)
17
+ registry.register(SecurityAnalyzer, overwrite=True)
18
+ registry.register(CodeQualityAnalyzer, overwrite=True)
19
+ registry.register(CiCdAnalyzer, overwrite=True)
20
+
21
+ __all__ = [
22
+ "CiCdAnalyzer",
23
+ "CodeQualityAnalyzer",
24
+ "DependencyAnalyzer",
25
+ "DocumentationAnalyzer",
26
+ "SecurityAnalyzer",
27
+ "TestingAnalyzer",
28
+ ]
@@ -0,0 +1,154 @@
1
+ """CI/CD analyzer inspecting GitHub workflows, triggers, permissions, and action pinning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from repoguard.core.context import ScanContext
11
+ from repoguard.core.findings import Category, Confidence, Finding, Severity
12
+ from repoguard.plugins.base import BaseAnalyzer
13
+
14
+
15
+ class CiCdAnalyzer(BaseAnalyzer):
16
+ """Analyzes GitHub Actions workflows for best practices, permissions, and security."""
17
+
18
+ name = "cicd"
19
+ description = "Inspects GitHub Actions workflows for permissions, branch pinning, and CI health"
20
+ category = Category.CICD
21
+ version = "1.0.0"
22
+
23
+ MUTABLE_BRANCHES = {"main", "master", "dev", "develop", "latest"}
24
+
25
+ def analyze(self, context: ScanContext) -> list[Finding]:
26
+ findings: list[Finding] = []
27
+
28
+ workflow_files = [
29
+ f
30
+ for f in context.files
31
+ if context.get_relative_path(f).startswith(".github/workflows/")
32
+ and f.suffix in [".yml", ".yaml"]
33
+ ]
34
+
35
+ # 1. Detect missing workflows
36
+ if not workflow_files:
37
+ findings.append(
38
+ Finding(
39
+ id="CICD001",
40
+ analyzer=self.name,
41
+ category=self.category,
42
+ severity=Severity.MEDIUM,
43
+ description="No GitHub Actions workflows found in .github/workflows/.",
44
+ remediation="Add a CI workflow to automatically build, test, and validate code changes.",
45
+ confidence=Confidence.HIGH,
46
+ )
47
+ )
48
+ return findings
49
+
50
+ # 2. Inspect each workflow file
51
+ for wf_file in workflow_files:
52
+ rel_path = context.get_relative_path(wf_file)
53
+ content = context.read_file(wf_file)
54
+ if not content:
55
+ continue
56
+
57
+ # Parse YAML safely
58
+ try:
59
+ data = yaml.safe_load(content) or {}
60
+ except yaml.YAMLError as exc:
61
+ findings.append(
62
+ Finding(
63
+ id="CICD005",
64
+ analyzer=self.name,
65
+ category=self.category,
66
+ severity=Severity.HIGH,
67
+ description=f"Malformed YAML in workflow '{rel_path}': {exc}",
68
+ file_path=rel_path,
69
+ remediation="Fix YAML syntax error in workflow file.",
70
+ confidence=Confidence.HIGH,
71
+ )
72
+ )
73
+ continue
74
+
75
+ self._check_workflow_permissions(data, rel_path, findings)
76
+ self._check_action_pinning(content, rel_path, findings)
77
+
78
+ return findings
79
+
80
+ def _check_workflow_permissions(
81
+ self,
82
+ data: dict[str, Any],
83
+ rel_path: str,
84
+ findings: list[Finding],
85
+ ) -> None:
86
+ """Inspect workflow permissions for principle of least privilege."""
87
+ permissions = data.get("permissions")
88
+
89
+ if permissions == "write-all":
90
+ findings.append(
91
+ Finding(
92
+ id="CICD002",
93
+ analyzer=self.name,
94
+ category=self.category,
95
+ severity=Severity.HIGH,
96
+ description="Workflow configures 'permissions: write-all', granting broad write access to GITHUB_TOKEN.",
97
+ file_path=rel_path,
98
+ remediation="Define fine-grained, read-only default permissions (e.g. `permissions: contents: read`).",
99
+ confidence=Confidence.HIGH,
100
+ )
101
+ )
102
+ elif permissions is None:
103
+ # Check if jobs define permissions
104
+ jobs = data.get("jobs", {})
105
+ has_job_permissions = any(
106
+ "permissions" in job for job in jobs.values() if isinstance(job, dict)
107
+ )
108
+ if not has_job_permissions:
109
+ findings.append(
110
+ Finding(
111
+ id="CICD003",
112
+ analyzer=self.name,
113
+ category=self.category,
114
+ severity=Severity.LOW,
115
+ description="Workflow lacks explicit least-privilege 'permissions' configuration.",
116
+ file_path=rel_path,
117
+ remediation="Explicitly specify top-level 'permissions: contents: read' to follow the principle of least privilege.",
118
+ confidence=Confidence.MEDIUM,
119
+ )
120
+ )
121
+
122
+ def _check_action_pinning(
123
+ self,
124
+ content: str,
125
+ rel_path: str,
126
+ findings: list[Finding],
127
+ ) -> None:
128
+ """Inspect action references for mutable branch tags."""
129
+ lines = content.splitlines()
130
+ for idx, line in enumerate(lines, start=1):
131
+ match = re.search(r"uses:\s*([a-zA-Z0-9_\-\./]+)@([a-zA-Z0-9_\-\.]+)", line)
132
+ if match:
133
+ action_name = match.group(1)
134
+ ref = match.group(2)
135
+
136
+ # Skip local actions (./) or docker actions
137
+ if action_name.startswith((".", "docker://")):
138
+ continue
139
+
140
+ if ref.lower() in self.MUTABLE_BRANCHES:
141
+ findings.append(
142
+ Finding(
143
+ id="CICD004",
144
+ analyzer=self.name,
145
+ category=self.category,
146
+ severity=Severity.MEDIUM,
147
+ description=f"Action '{action_name}' is pinned to mutable branch '@{ref}'.",
148
+ file_path=rel_path,
149
+ line_number=idx,
150
+ evidence=line.strip(),
151
+ remediation="Pin third-party GitHub Actions to a full commit SHA or tagged release (e.g. @v4.0.0).",
152
+ confidence=Confidence.HIGH,
153
+ )
154
+ )
@@ -0,0 +1,198 @@
1
+ """Code quality analyzer inspecting file sizes, function complexity, and maintainability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from repoguard.core.context import ScanContext
8
+ from repoguard.core.findings import Category, Confidence, Finding, Severity
9
+ from repoguard.plugins.base import BaseAnalyzer
10
+
11
+
12
+ class CyclomaticComplexityVisitor(ast.NodeVisitor):
13
+ """Calculates McCabe cyclomatic complexity for an AST function node."""
14
+
15
+ def __init__(self) -> None:
16
+ self.complexity = 1
17
+
18
+ def visit_If(self, node: ast.If) -> None:
19
+ self.complexity += 1
20
+ self.generic_visit(node)
21
+
22
+ def visit_For(self, node: ast.For) -> None:
23
+ self.complexity += 1
24
+ self.generic_visit(node)
25
+
26
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
27
+ self.complexity += 1
28
+ self.generic_visit(node)
29
+
30
+ def visit_While(self, node: ast.While) -> None:
31
+ self.complexity += 1
32
+ self.generic_visit(node)
33
+
34
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
35
+ self.complexity += 1
36
+ self.generic_visit(node)
37
+
38
+ def visit_With(self, node: ast.With) -> None:
39
+ self.complexity += 1
40
+ self.generic_visit(node)
41
+
42
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
43
+ self.complexity += 1
44
+ self.generic_visit(node)
45
+
46
+ def visit_Assert(self, node: ast.Assert) -> None:
47
+ self.complexity += 1
48
+ self.generic_visit(node)
49
+
50
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
51
+ self.complexity += len(node.values) - 1
52
+ self.generic_visit(node)
53
+
54
+
55
+ class CodeQualityAnalyzer(BaseAnalyzer):
56
+ """Inspects code maintainability: large files, complex functions, and poor patterns."""
57
+
58
+ name = "code_quality"
59
+ description = (
60
+ "Checks for oversized files, high cyclomatic complexity, and maintainability antipatterns"
61
+ )
62
+ category = Category.CODE_QUALITY
63
+ version = "1.0.0"
64
+
65
+ def analyze(self, context: ScanContext) -> list[Finding]:
66
+ findings: list[Finding] = []
67
+ limits = context.config.code_quality
68
+
69
+ python_files = context.list_files(extension=".py")
70
+ for file_path in python_files:
71
+ rel_path = context.get_relative_path(file_path)
72
+ content = context.read_file(file_path)
73
+ if content is None:
74
+ continue
75
+
76
+ lines = content.splitlines()
77
+
78
+ # 1. Check file size
79
+ if len(lines) > limits.max_file_lines:
80
+ findings.append(
81
+ Finding(
82
+ id="QUAL001",
83
+ analyzer=self.name,
84
+ category=self.category,
85
+ severity=Severity.LOW,
86
+ description=f"File exceeds maximum recommended length ({len(lines)} > {limits.max_file_lines} lines).",
87
+ file_path=rel_path,
88
+ remediation="Decompose this module into smaller, focused single-responsibility files.",
89
+ confidence=Confidence.HIGH,
90
+ metadata={"lines": len(lines), "max": limits.max_file_lines},
91
+ )
92
+ )
93
+
94
+ # 2. Parse Python AST for modular quality checks
95
+ try:
96
+ tree = ast.parse(content, filename=rel_path)
97
+ except SyntaxError:
98
+ # Syntax errors will be reported or skipped safely
99
+ continue
100
+
101
+ self._analyze_python_ast(tree, rel_path, limits, findings)
102
+
103
+ return findings
104
+
105
+ def _analyze_python_ast(
106
+ self,
107
+ tree: ast.AST,
108
+ rel_path: str,
109
+ limits,
110
+ findings: list[Finding],
111
+ ) -> None:
112
+ for node in ast.walk(tree):
113
+ # Check wildcard imports
114
+ if isinstance(node, ast.ImportFrom):
115
+ for alias in node.names:
116
+ if alias.name == "*":
117
+ findings.append(
118
+ Finding(
119
+ id="QUAL002",
120
+ analyzer=self.name,
121
+ category=self.category,
122
+ severity=Severity.LOW,
123
+ description=f"Wildcard import 'from {node.module} import *' pollutes namespace.",
124
+ file_path=rel_path,
125
+ line_number=node.lineno,
126
+ remediation="Explicitly import required functions and classes instead of '*'.",
127
+ confidence=Confidence.HIGH,
128
+ )
129
+ )
130
+
131
+ # Check function definitions
132
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
133
+ func_name = node.name
134
+ func_line = node.lineno
135
+ func_length = (node.end_lineno - node.lineno + 1) if node.end_lineno else 1
136
+
137
+ # Check function length
138
+ if func_length > limits.max_function_lines:
139
+ findings.append(
140
+ Finding(
141
+ id="QUAL003",
142
+ analyzer=self.name,
143
+ category=self.category,
144
+ severity=Severity.LOW,
145
+ description=f"Function '{func_name}' is too long ({func_length} > {limits.max_function_lines} lines).",
146
+ file_path=rel_path,
147
+ line_number=func_line,
148
+ remediation="Refactor the function into smaller helper functions.",
149
+ confidence=Confidence.HIGH,
150
+ metadata={"length": func_length, "max": limits.max_function_lines},
151
+ )
152
+ )
153
+
154
+ # Check argument count
155
+ total_args = (
156
+ len(node.args.args) + len(node.args.posonlyargs) + len(node.args.kwonlyargs)
157
+ )
158
+ if total_args > limits.max_function_arguments:
159
+ findings.append(
160
+ Finding(
161
+ id="QUAL004",
162
+ analyzer=self.name,
163
+ category=self.category,
164
+ severity=Severity.LOW,
165
+ description=f"Function '{func_name}' has excessive parameters ({total_args} > {limits.max_function_arguments}).",
166
+ file_path=rel_path,
167
+ line_number=func_line,
168
+ remediation="Group related parameters into a configuration model, dataclass, or object.",
169
+ confidence=Confidence.MEDIUM,
170
+ metadata={"args": total_args, "max": limits.max_function_arguments},
171
+ )
172
+ )
173
+
174
+ # Calculate cyclomatic complexity
175
+ visitor = CyclomaticComplexityVisitor()
176
+ # Visit function body directly
177
+ for stmt in node.body:
178
+ visitor.visit(stmt)
179
+ complexity = visitor.complexity
180
+
181
+ if complexity > limits.max_cyclomatic_complexity:
182
+ findings.append(
183
+ Finding(
184
+ id="QUAL005",
185
+ analyzer=self.name,
186
+ category=self.category,
187
+ severity=Severity.MEDIUM,
188
+ description=f"Function '{func_name}' has high cyclomatic complexity ({complexity} > {limits.max_cyclomatic_complexity}).",
189
+ file_path=rel_path,
190
+ line_number=func_line,
191
+ remediation="Simplify conditionals and extract branching logic into separate functions.",
192
+ confidence=Confidence.HIGH,
193
+ metadata={
194
+ "complexity": complexity,
195
+ "max": limits.max_cyclomatic_complexity,
196
+ },
197
+ )
198
+ )
@@ -0,0 +1,286 @@
1
+ """Dependency analyzer checking pinned versions, outdated packages, and OSV vulnerabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import requests
11
+
12
+ from repoguard.core.context import ScanContext
13
+ from repoguard.core.findings import Category, Confidence, Finding, Severity
14
+ from repoguard.plugins.base import BaseAnalyzer
15
+
16
+ OSV_API_URL = "https://api.osv.dev/v1/query"
17
+ PYPI_API_URL = "https://pypi.org/pypi/{package}/json"
18
+ NPM_REGISTRY_URL = "https://registry.npmjs.org/{package}"
19
+
20
+
21
+ @dataclass
22
+ class ParsedDependency:
23
+ name: str
24
+ specifier: str
25
+ pinned_version: str | None
26
+ ecosystem: str # "PyPI" or "npm"
27
+ file_path: str
28
+ line_number: int | None = None
29
+
30
+
31
+ class DependencyAnalyzer(BaseAnalyzer):
32
+ """Analyzes repository dependencies for security vulnerabilities and update status."""
33
+
34
+ name = "dependencies"
35
+ description = "Inspects dependencies for unpinned versions, outdated releases, and CVEs via OSV"
36
+ category = Category.DEPENDENCIES
37
+ version = "1.0.0"
38
+
39
+ def __init__(self, config: dict[str, Any] | None = None) -> None:
40
+ super().__init__(config)
41
+ self._vuln_cache: dict[str, list[dict[str, Any]]] = {}
42
+ self._latest_version_cache: dict[str, str | None] = {}
43
+
44
+ def analyze(self, context: ScanContext) -> list[Finding]:
45
+ findings: list[Finding] = []
46
+ dependencies = self._discover_dependencies(context)
47
+
48
+ if not dependencies and not self._has_manifests(context):
49
+ # No dependencies found and no manifests
50
+ return findings
51
+
52
+ for dep in dependencies:
53
+ # 1. Check for unpinned dependencies
54
+ if not dep.pinned_version:
55
+ findings.append(
56
+ Finding(
57
+ id="DEP001",
58
+ analyzer=self.name,
59
+ category=self.category,
60
+ severity=Severity.LOW,
61
+ description=f"Dependency '{dep.name}' is unpinned in {dep.file_path}",
62
+ file_path=dep.file_path,
63
+ line_number=dep.line_number,
64
+ evidence=f"Specification: {dep.name}{dep.specifier}",
65
+ remediation=f"Pin '{dep.name}' to a tested, exact version (e.g. {dep.name}==x.y.z)",
66
+ confidence=Confidence.HIGH,
67
+ )
68
+ )
69
+
70
+ # 2. Check network-dependent lookups if enabled
71
+ if context.network_enabled and dep.pinned_version:
72
+ timeout = context.config.network.timeout_seconds
73
+
74
+ # A. Known vulnerabilities via OSV API
75
+ vulns = self._check_osv_vulnerabilities(dep, timeout=timeout)
76
+ for vuln in vulns:
77
+ vuln_id = vuln.get("id", "UNKNOWN-VULN")
78
+ summary = (
79
+ vuln.get("summary") or vuln.get("details") or "Known security vulnerability"
80
+ )
81
+ # Truncate summary for neatness
82
+ short_summary = (summary[:150] + "...") if len(summary) > 150 else summary
83
+
84
+ findings.append(
85
+ Finding(
86
+ id="DEP002",
87
+ analyzer=self.name,
88
+ category=self.category,
89
+ severity=Severity.HIGH,
90
+ description=f"Vulnerability {vuln_id} detected in {dep.name}@{dep.pinned_version}: {short_summary}",
91
+ file_path=dep.file_path,
92
+ line_number=dep.line_number,
93
+ evidence=f"Package: {dep.name}=={dep.pinned_version} (Advisory: {vuln_id})",
94
+ remediation=f"Upgrade '{dep.name}' to a patched version resolving advisory {vuln_id}",
95
+ confidence=Confidence.HIGH,
96
+ metadata={"advisory_id": vuln_id, "ecosystem": dep.ecosystem},
97
+ )
98
+ )
99
+
100
+ # B. Outdated dependency check via package registries
101
+ latest = self._fetch_latest_version(dep, timeout=timeout)
102
+ if latest and latest != dep.pinned_version:
103
+ findings.append(
104
+ Finding(
105
+ id="DEP003",
106
+ analyzer=self.name,
107
+ category=self.category,
108
+ severity=Severity.LOW,
109
+ description=f"Dependency '{dep.name}' is outdated ({dep.pinned_version} -> {latest})",
110
+ file_path=dep.file_path,
111
+ line_number=dep.line_number,
112
+ evidence=f"Current: {dep.pinned_version}, Latest: {latest}",
113
+ remediation=f"Consider testing and updating '{dep.name}' to release {latest}",
114
+ confidence=Confidence.MEDIUM,
115
+ metadata={"latest_version": latest},
116
+ )
117
+ )
118
+
119
+ return findings
120
+
121
+ def _has_manifests(self, context: ScanContext) -> bool:
122
+ return any(
123
+ context.has_file(f)
124
+ for f in ["requirements.txt", "pyproject.toml", "Pipfile", "package.json"]
125
+ )
126
+
127
+ def _discover_dependencies(self, context: ScanContext) -> list[ParsedDependency]:
128
+ """Parse dependencies from requirements.txt, pyproject.toml, and package.json."""
129
+ deps: list[ParsedDependency] = []
130
+
131
+ # Python: requirements.txt
132
+ for req_file in context.find_files_by_name(["requirements.txt", "requirements-dev.txt"]):
133
+ rel_path = context.get_relative_path(req_file)
134
+ lines = context.read_file_lines(req_file) or []
135
+ for idx, line in enumerate(lines, start=1):
136
+ clean = line.strip()
137
+ if not clean or clean.startswith(("#", "-", "--")):
138
+ continue
139
+ match = re.match(r"^([a-zA-Z0-9_\-\.]+)\s*([=><~^!]+.*)?$", clean)
140
+ if match:
141
+ name = match.group(1).lower()
142
+ spec = match.group(2) or ""
143
+ pinned = None
144
+ pin_match = re.search(r"==\s*([0-9a-zA-Z\.\-]+)", spec)
145
+ if pin_match:
146
+ pinned = pin_match.group(1)
147
+ deps.append(
148
+ ParsedDependency(
149
+ name=name,
150
+ specifier=spec,
151
+ pinned_version=pinned,
152
+ ecosystem="PyPI",
153
+ file_path=rel_path,
154
+ line_number=idx,
155
+ )
156
+ )
157
+
158
+ # Python: pyproject.toml
159
+ pyproject_files = context.find_files_by_name(["pyproject.toml"])
160
+ for pyproject in pyproject_files:
161
+ rel_path = context.get_relative_path(pyproject)
162
+ content = context.read_file(pyproject)
163
+ if content:
164
+ try:
165
+ import tomllib
166
+
167
+ data = tomllib.loads(content)
168
+ raw_deps = data.get("project", {}).get("dependencies", [])
169
+ for item in raw_deps:
170
+ if isinstance(item, str):
171
+ match = re.match(
172
+ r"^([a-zA-Z0-9_\-\.]+)\s*([=><~^!]+.*)?$", item.strip()
173
+ )
174
+ if match:
175
+ name = match.group(1).lower()
176
+ spec = match.group(2) or ""
177
+ pinned = None
178
+ pin_match = re.search(r"==\s*([0-9a-zA-Z\.\-]+)", spec)
179
+ if pin_match:
180
+ pinned = pin_match.group(1)
181
+ deps.append(
182
+ ParsedDependency(
183
+ name=name,
184
+ specifier=spec,
185
+ pinned_version=pinned,
186
+ ecosystem="PyPI",
187
+ file_path=rel_path,
188
+ )
189
+ )
190
+ except Exception:
191
+ pass
192
+
193
+ # JavaScript/Node: package.json
194
+ pkg_json_files = context.find_files_by_name(["package.json"])
195
+ for pkg_file in pkg_json_files:
196
+ rel_path = context.get_relative_path(pkg_file)
197
+ content = context.read_file(pkg_file)
198
+ if content:
199
+ try:
200
+ data = json.loads(content)
201
+ all_deps: dict[str, str] = {}
202
+ all_deps.update(data.get("dependencies", {}))
203
+ all_deps.update(data.get("devDependencies", {}))
204
+
205
+ for name, spec in all_deps.items():
206
+ clean_spec = str(spec).strip()
207
+ # Strict pin e.g. "1.2.3" without ^ or ~
208
+ pinned = None
209
+ if re.match(r"^[0-9]+\.[0-9]+\.[0-9]+[0-9a-zA-Z\.\-]*$", clean_spec):
210
+ pinned = clean_spec
211
+ deps.append(
212
+ ParsedDependency(
213
+ name=name,
214
+ specifier=clean_spec,
215
+ pinned_version=pinned,
216
+ ecosystem="npm",
217
+ file_path=rel_path,
218
+ )
219
+ )
220
+ except Exception:
221
+ pass
222
+
223
+ return deps
224
+
225
+ def _check_osv_vulnerabilities(
226
+ self,
227
+ dep: ParsedDependency,
228
+ timeout: float = 10.0,
229
+ ) -> list[dict[str, Any]]:
230
+ """Query the open-source OSV vulnerability database for known CVEs/GHSAs."""
231
+ if not dep.pinned_version:
232
+ return []
233
+
234
+ cache_key = f"{dep.ecosystem}:{dep.name}:{dep.pinned_version}"
235
+ if cache_key in self._vuln_cache:
236
+ return self._vuln_cache[cache_key]
237
+
238
+ payload = {
239
+ "package": {"name": dep.name, "ecosystem": dep.ecosystem},
240
+ "version": dep.pinned_version,
241
+ }
242
+
243
+ try:
244
+ resp = requests.post(OSV_API_URL, json=payload, timeout=timeout)
245
+ if resp.status_code == 200:
246
+ data = resp.json()
247
+ vulns = data.get("vulns", [])
248
+ self._vuln_cache[cache_key] = vulns
249
+ return vulns
250
+ except requests.RequestException:
251
+ # Network failure or API unavailable handled gracefully
252
+ pass
253
+
254
+ self._vuln_cache[cache_key] = []
255
+ return []
256
+
257
+ def _fetch_latest_version(
258
+ self,
259
+ dep: ParsedDependency,
260
+ timeout: float = 10.0,
261
+ ) -> str | None:
262
+ """Fetch latest version from PyPI or npm registry."""
263
+ cache_key = f"{dep.ecosystem}:{dep.name}"
264
+ if cache_key in self._latest_version_cache:
265
+ return self._latest_version_cache[cache_key]
266
+
267
+ try:
268
+ if dep.ecosystem == "PyPI":
269
+ url = PYPI_API_URL.format(package=dep.name)
270
+ resp = requests.get(url, timeout=timeout)
271
+ if resp.status_code == 200:
272
+ latest = resp.json().get("info", {}).get("version")
273
+ self._latest_version_cache[cache_key] = latest
274
+ return latest
275
+ elif dep.ecosystem == "npm":
276
+ url = NPM_REGISTRY_URL.format(package=dep.name)
277
+ resp = requests.get(url, timeout=timeout)
278
+ if resp.status_code == 200:
279
+ latest = resp.json().get("dist-tags", {}).get("latest")
280
+ self._latest_version_cache[cache_key] = latest
281
+ return latest
282
+ except requests.RequestException:
283
+ pass
284
+
285
+ self._latest_version_cache[cache_key] = None
286
+ return None