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 +11 -0
- repoguard/analyzers/__init__.py +28 -0
- repoguard/analyzers/cicd.py +154 -0
- repoguard/analyzers/code_quality.py +198 -0
- repoguard/analyzers/dependencies.py +286 -0
- repoguard/analyzers/documentation.py +206 -0
- repoguard/analyzers/security.py +454 -0
- repoguard/analyzers/testing.py +226 -0
- repoguard/cli.py +312 -0
- repoguard/config.py +153 -0
- repoguard/core/__init__.py +1 -0
- repoguard/core/context.py +201 -0
- repoguard/core/findings.py +109 -0
- repoguard/core/registry.py +83 -0
- repoguard/core/scanner.py +207 -0
- repoguard/core/scoring.py +173 -0
- repoguard/plugins/__init__.py +15 -0
- repoguard/plugins/base.py +40 -0
- repoguard/plugins/discovery.py +103 -0
- repoguard/plugins/manager.py +32 -0
- repoguard/reporters/__init__.py +9 -0
- repoguard/reporters/json_reporter.py +30 -0
- repoguard/reporters/markdown.py +144 -0
- repoguard/reporters/terminal.py +134 -0
- repoguard/utils/__init__.py +1 -0
- repoguard/utils/filesystem.py +133 -0
- repoguard/utils/versioning.py +49 -0
- repoguard_tool-0.1.0.dist-info/METADATA +204 -0
- repoguard_tool-0.1.0.dist-info/RECORD +32 -0
- repoguard_tool-0.1.0.dist-info/WHEEL +4 -0
- repoguard_tool-0.1.0.dist-info/entry_points.txt +2 -0
- repoguard_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
repoguard/__init__.py
ADDED
|
@@ -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
|