cve-sentinel 0.1.2__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.
- cve_sentinel/__init__.py +4 -0
- cve_sentinel/__main__.py +18 -0
- cve_sentinel/analyzers/__init__.py +19 -0
- cve_sentinel/analyzers/base.py +274 -0
- cve_sentinel/analyzers/go.py +186 -0
- cve_sentinel/analyzers/maven.py +291 -0
- cve_sentinel/analyzers/npm.py +586 -0
- cve_sentinel/analyzers/php.py +238 -0
- cve_sentinel/analyzers/python.py +435 -0
- cve_sentinel/analyzers/ruby.py +182 -0
- cve_sentinel/analyzers/rust.py +199 -0
- cve_sentinel/cli.py +517 -0
- cve_sentinel/config.py +347 -0
- cve_sentinel/fetchers/__init__.py +22 -0
- cve_sentinel/fetchers/nvd.py +544 -0
- cve_sentinel/fetchers/osv.py +719 -0
- cve_sentinel/matcher.py +496 -0
- cve_sentinel/reporter.py +549 -0
- cve_sentinel/scanner.py +513 -0
- cve_sentinel/scanners/__init__.py +13 -0
- cve_sentinel/scanners/import_scanner.py +1121 -0
- cve_sentinel/utils/__init__.py +5 -0
- cve_sentinel/utils/cache.py +61 -0
- cve_sentinel-0.1.2.dist-info/METADATA +454 -0
- cve_sentinel-0.1.2.dist-info/RECORD +28 -0
- cve_sentinel-0.1.2.dist-info/WHEEL +4 -0
- cve_sentinel-0.1.2.dist-info/entry_points.txt +2 -0
- cve_sentinel-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""PHP Composer dependency analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from cve_sentinel.analyzers.base import (
|
|
11
|
+
AnalyzerRegistry,
|
|
12
|
+
BaseAnalyzer,
|
|
13
|
+
FileDetector,
|
|
14
|
+
Package,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PhpAnalyzer(BaseAnalyzer):
|
|
19
|
+
"""Analyzer for PHP Composer.
|
|
20
|
+
|
|
21
|
+
Supports:
|
|
22
|
+
- composer.json (Level 1: direct dependencies)
|
|
23
|
+
- composer.lock (Level 2: transitive dependencies)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ecosystem(self) -> str:
|
|
28
|
+
"""Return the ecosystem name."""
|
|
29
|
+
return "packagist"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def manifest_patterns(self) -> List[str]:
|
|
33
|
+
"""Return glob patterns for manifest files."""
|
|
34
|
+
default_patterns = ["composer.json"]
|
|
35
|
+
custom = self._custom_patterns.get("manifests", [])
|
|
36
|
+
return default_patterns + custom
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def lock_patterns(self) -> List[str]:
|
|
40
|
+
"""Return glob patterns for lock files."""
|
|
41
|
+
default_patterns = ["composer.lock"]
|
|
42
|
+
custom = self._custom_patterns.get("locks", [])
|
|
43
|
+
return default_patterns + custom
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
analysis_level: int = 2,
|
|
48
|
+
custom_patterns: Optional[Dict[str, List[str]]] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize PHP analyzer.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
analysis_level: Analysis depth (1=manifest only, 2=include lock files)
|
|
54
|
+
custom_patterns: Optional custom file patterns {"manifests": [...], "locks": [...]}
|
|
55
|
+
"""
|
|
56
|
+
self.analysis_level = analysis_level
|
|
57
|
+
self._custom_patterns = custom_patterns or {}
|
|
58
|
+
self._file_detector = FileDetector()
|
|
59
|
+
|
|
60
|
+
def detect_files(self, path: Path) -> List[Path]:
|
|
61
|
+
"""Detect PHP dependency files."""
|
|
62
|
+
patterns = self.manifest_patterns.copy()
|
|
63
|
+
if self.analysis_level >= 2:
|
|
64
|
+
patterns.extend(self.lock_patterns)
|
|
65
|
+
return self._file_detector.find_files(path, patterns)
|
|
66
|
+
|
|
67
|
+
def parse(self, file_path: Path) -> List[Package]:
|
|
68
|
+
"""Parse a PHP dependency file."""
|
|
69
|
+
if file_path.name == "composer.json":
|
|
70
|
+
return self._parse_composer_json(file_path)
|
|
71
|
+
elif file_path.name == "composer.lock":
|
|
72
|
+
return self._parse_composer_lock(file_path)
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def _parse_composer_json(self, file_path: Path) -> List[Package]:
|
|
76
|
+
"""Parse composer.json file."""
|
|
77
|
+
packages: List[Package] = []
|
|
78
|
+
content = file_path.read_text(encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(content)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return packages
|
|
84
|
+
|
|
85
|
+
# Parse require section
|
|
86
|
+
require = data.get("require", {})
|
|
87
|
+
for name, version_spec in require.items():
|
|
88
|
+
# Skip PHP version constraints and extensions
|
|
89
|
+
if name == "php" or name.startswith("ext-"):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
version = self._normalize_version(version_spec)
|
|
93
|
+
line_num = self._find_line_number(content, name, "require")
|
|
94
|
+
|
|
95
|
+
packages.append(
|
|
96
|
+
Package(
|
|
97
|
+
name=name,
|
|
98
|
+
version=version,
|
|
99
|
+
ecosystem=self.ecosystem,
|
|
100
|
+
source_file=file_path,
|
|
101
|
+
source_line=line_num,
|
|
102
|
+
is_direct=True,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Parse require-dev section
|
|
107
|
+
require_dev = data.get("require-dev", {})
|
|
108
|
+
for name, version_spec in require_dev.items():
|
|
109
|
+
if name == "php" or name.startswith("ext-"):
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
version = self._normalize_version(version_spec)
|
|
113
|
+
line_num = self._find_line_number(content, name, "require-dev")
|
|
114
|
+
|
|
115
|
+
packages.append(
|
|
116
|
+
Package(
|
|
117
|
+
name=name,
|
|
118
|
+
version=version,
|
|
119
|
+
ecosystem=self.ecosystem,
|
|
120
|
+
source_file=file_path,
|
|
121
|
+
source_line=line_num,
|
|
122
|
+
is_direct=True,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return packages
|
|
127
|
+
|
|
128
|
+
def _parse_composer_lock(self, file_path: Path) -> List[Package]:
|
|
129
|
+
"""Parse composer.lock file."""
|
|
130
|
+
packages: List[Package] = []
|
|
131
|
+
content = file_path.read_text(encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(content)
|
|
135
|
+
except json.JSONDecodeError:
|
|
136
|
+
return packages
|
|
137
|
+
|
|
138
|
+
# Parse packages section
|
|
139
|
+
pkg_list = data.get("packages", [])
|
|
140
|
+
for pkg in pkg_list:
|
|
141
|
+
name = pkg.get("name", "")
|
|
142
|
+
version = pkg.get("version", "")
|
|
143
|
+
|
|
144
|
+
if not name or not version:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Normalize version (remove 'v' prefix)
|
|
148
|
+
if version.startswith("v"):
|
|
149
|
+
version = version[1:]
|
|
150
|
+
|
|
151
|
+
packages.append(
|
|
152
|
+
Package(
|
|
153
|
+
name=name,
|
|
154
|
+
version=version,
|
|
155
|
+
ecosystem=self.ecosystem,
|
|
156
|
+
source_file=file_path,
|
|
157
|
+
source_line=None,
|
|
158
|
+
is_direct=False,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Parse packages-dev section
|
|
163
|
+
pkg_dev_list = data.get("packages-dev", [])
|
|
164
|
+
for pkg in pkg_dev_list:
|
|
165
|
+
name = pkg.get("name", "")
|
|
166
|
+
version = pkg.get("version", "")
|
|
167
|
+
|
|
168
|
+
if not name or not version:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
if version.startswith("v"):
|
|
172
|
+
version = version[1:]
|
|
173
|
+
|
|
174
|
+
packages.append(
|
|
175
|
+
Package(
|
|
176
|
+
name=name,
|
|
177
|
+
version=version,
|
|
178
|
+
ecosystem=self.ecosystem,
|
|
179
|
+
source_file=file_path,
|
|
180
|
+
source_line=None,
|
|
181
|
+
is_direct=False,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return packages
|
|
186
|
+
|
|
187
|
+
def _normalize_version(self, version_spec: str) -> str:
|
|
188
|
+
"""Normalize Composer version specifier."""
|
|
189
|
+
if not version_spec:
|
|
190
|
+
return "*"
|
|
191
|
+
|
|
192
|
+
# Handle dev versions
|
|
193
|
+
if version_spec.startswith("dev-"):
|
|
194
|
+
return version_spec
|
|
195
|
+
|
|
196
|
+
# Remove operators: ^, ~, >=, <=, >, <, =, ||
|
|
197
|
+
version = re.sub(r"^[\^~>=<|]+\s*", "", version_spec)
|
|
198
|
+
|
|
199
|
+
# Handle OR: take first version
|
|
200
|
+
if "|" in version:
|
|
201
|
+
version = version.split("|")[0].strip()
|
|
202
|
+
version = re.sub(r"^[\^~>=<]+\s*", "", version)
|
|
203
|
+
|
|
204
|
+
# Handle range with space: ">1.0 <2.0" -> take first
|
|
205
|
+
if " " in version:
|
|
206
|
+
version = version.split()[0].strip()
|
|
207
|
+
version = re.sub(r"^[\^~>=<]+\s*", "", version)
|
|
208
|
+
|
|
209
|
+
# Remove 'v' prefix
|
|
210
|
+
if version.startswith("v"):
|
|
211
|
+
version = version[1:]
|
|
212
|
+
|
|
213
|
+
# Handle wildcard: 1.* -> 1.0
|
|
214
|
+
version = re.sub(r"\.\*$", ".0", version)
|
|
215
|
+
|
|
216
|
+
return version.strip() or "*"
|
|
217
|
+
|
|
218
|
+
def _find_line_number(self, content: str, package_name: str, section: str) -> int | None:
|
|
219
|
+
"""Find line number of a package in composer.json."""
|
|
220
|
+
lines = content.split("\n")
|
|
221
|
+
in_section = False
|
|
222
|
+
escaped_name = re.escape(package_name)
|
|
223
|
+
|
|
224
|
+
for i, line in enumerate(lines, start=1):
|
|
225
|
+
if f'"{section}"' in line:
|
|
226
|
+
in_section = True
|
|
227
|
+
elif in_section:
|
|
228
|
+
if re.match(r"^\s*\}", line):
|
|
229
|
+
in_section = False
|
|
230
|
+
elif re.search(rf'"{escaped_name}"', line):
|
|
231
|
+
return i
|
|
232
|
+
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def register() -> None:
|
|
237
|
+
"""Register the PHP analyzer."""
|
|
238
|
+
AnalyzerRegistry.get_instance().register(PhpAnalyzer())
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Python dependency analyzer for pip, poetry, and pipenv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
from cve_sentinel.analyzers.base import BaseAnalyzer, FileDetector, Package
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PythonAnalyzer(BaseAnalyzer):
|
|
20
|
+
"""Analyzer for Python dependencies (pip, poetry, pipenv)."""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def ecosystem(self) -> str:
|
|
24
|
+
return "pypi"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def manifest_patterns(self) -> List[str]:
|
|
28
|
+
default_patterns = ["requirements.txt", "requirements*.txt", "pyproject.toml", "Pipfile"]
|
|
29
|
+
custom = self._custom_patterns.get("manifests", [])
|
|
30
|
+
return default_patterns + custom
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def lock_patterns(self) -> List[str]:
|
|
34
|
+
default_patterns = ["poetry.lock", "Pipfile.lock"]
|
|
35
|
+
custom = self._custom_patterns.get("locks", [])
|
|
36
|
+
return default_patterns + custom
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
exclude_patterns: Optional[List[str]] = None,
|
|
41
|
+
custom_patterns: Optional[Dict[str, List[str]]] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize the analyzer.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
exclude_patterns: Glob patterns to exclude from file detection
|
|
47
|
+
custom_patterns: Optional custom file patterns {"manifests": [...], "locks": [...]}
|
|
48
|
+
"""
|
|
49
|
+
self._custom_patterns = custom_patterns or {}
|
|
50
|
+
self._file_detector = FileDetector(exclude_patterns=exclude_patterns)
|
|
51
|
+
|
|
52
|
+
def detect_files(self, path: Path) -> List[Path]:
|
|
53
|
+
"""Detect Python dependency files in the given path."""
|
|
54
|
+
all_patterns = self.manifest_patterns + self.lock_patterns
|
|
55
|
+
return self._file_detector.find_files(path, all_patterns)
|
|
56
|
+
|
|
57
|
+
def parse(self, file_path: Path) -> List[Package]:
|
|
58
|
+
"""Parse a Python dependency file and return packages."""
|
|
59
|
+
filename = file_path.name.lower()
|
|
60
|
+
|
|
61
|
+
if filename == "requirements.txt" or filename.startswith("requirements"):
|
|
62
|
+
if filename.endswith(".txt"):
|
|
63
|
+
return self._parse_requirements_txt(file_path)
|
|
64
|
+
elif filename == "pyproject.toml":
|
|
65
|
+
return self._parse_pyproject_toml(file_path)
|
|
66
|
+
elif filename == "pipfile":
|
|
67
|
+
return self._parse_pipfile(file_path)
|
|
68
|
+
elif filename == "poetry.lock":
|
|
69
|
+
return self._parse_poetry_lock(file_path)
|
|
70
|
+
elif filename == "pipfile.lock":
|
|
71
|
+
return self._parse_pipfile_lock(file_path)
|
|
72
|
+
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def _parse_requirements_txt(
|
|
76
|
+
self, file_path: Path, visited: Optional[set] = None
|
|
77
|
+
) -> List[Package]:
|
|
78
|
+
"""Parse requirements.txt file.
|
|
79
|
+
|
|
80
|
+
Supports:
|
|
81
|
+
- package==version
|
|
82
|
+
- package>=version
|
|
83
|
+
- package[extra]==version
|
|
84
|
+
- Comments (#)
|
|
85
|
+
- -r includes
|
|
86
|
+
"""
|
|
87
|
+
if visited is None:
|
|
88
|
+
visited = set()
|
|
89
|
+
|
|
90
|
+
# Prevent infinite recursion
|
|
91
|
+
abs_path = file_path.resolve()
|
|
92
|
+
if abs_path in visited:
|
|
93
|
+
return []
|
|
94
|
+
visited.add(abs_path)
|
|
95
|
+
|
|
96
|
+
packages: List[Package] = []
|
|
97
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
98
|
+
|
|
99
|
+
for line_num, line in enumerate(content.splitlines(), start=1):
|
|
100
|
+
line = line.strip()
|
|
101
|
+
|
|
102
|
+
# Skip empty lines and comments
|
|
103
|
+
if not line or line.startswith("#"):
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Handle -r includes
|
|
107
|
+
if line.startswith("-r ") or line.startswith("--requirement "):
|
|
108
|
+
include_path = line.split(None, 1)[1].strip()
|
|
109
|
+
include_file = file_path.parent / include_path
|
|
110
|
+
if include_file.exists():
|
|
111
|
+
packages.extend(self._parse_requirements_txt(include_file, visited))
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Skip other flags
|
|
115
|
+
if line.startswith("-"):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Parse package specification
|
|
119
|
+
parsed = self._parse_requirement_line(line)
|
|
120
|
+
if parsed:
|
|
121
|
+
name, version = parsed
|
|
122
|
+
packages.append(
|
|
123
|
+
Package(
|
|
124
|
+
name=name,
|
|
125
|
+
version=version,
|
|
126
|
+
ecosystem=self.ecosystem,
|
|
127
|
+
source_file=file_path,
|
|
128
|
+
source_line=line_num,
|
|
129
|
+
is_direct=True,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return packages
|
|
134
|
+
|
|
135
|
+
def _parse_requirement_line(self, line: str) -> Optional[Tuple[str, str]]:
|
|
136
|
+
"""Parse a single requirement line.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Tuple of (package_name, version) or None if unparseable
|
|
140
|
+
"""
|
|
141
|
+
# Remove inline comments
|
|
142
|
+
if " #" in line:
|
|
143
|
+
line = line.split(" #")[0].strip()
|
|
144
|
+
|
|
145
|
+
# Remove environment markers
|
|
146
|
+
if ";" in line:
|
|
147
|
+
line = line.split(";")[0].strip()
|
|
148
|
+
|
|
149
|
+
# Handle extras: package[extra1,extra2]==version
|
|
150
|
+
extras_match = re.match(r"^([a-zA-Z0-9_.-]+)\[([^\]]+)\](.*)$", line)
|
|
151
|
+
if extras_match:
|
|
152
|
+
name = extras_match.group(1)
|
|
153
|
+
rest = extras_match.group(3).strip()
|
|
154
|
+
# Parse version from rest (e.g., "==2.28.0")
|
|
155
|
+
version_only_pattern = r"^(==|>=|<=|~=|!=|>|<)\s*([a-zA-Z0-9_.*+-]+)"
|
|
156
|
+
version_match = re.match(version_only_pattern, rest)
|
|
157
|
+
if version_match:
|
|
158
|
+
version = version_match.group(2)
|
|
159
|
+
return self._normalize_package_name(name), version
|
|
160
|
+
elif not rest:
|
|
161
|
+
return self._normalize_package_name(name), "*"
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
# Match version specifiers
|
|
165
|
+
# Patterns: ==, >=, <=, ~=, !=, >, <
|
|
166
|
+
version_pattern = r"^([a-zA-Z0-9_.-]+)\s*(==|>=|<=|~=|!=|>|<)\s*([a-zA-Z0-9_.*+-]+)"
|
|
167
|
+
match = re.match(version_pattern, line)
|
|
168
|
+
|
|
169
|
+
if match:
|
|
170
|
+
pkg_name = match.group(1)
|
|
171
|
+
version = match.group(3)
|
|
172
|
+
return self._normalize_package_name(pkg_name), version
|
|
173
|
+
|
|
174
|
+
# Package without version
|
|
175
|
+
simple_match = re.match(r"^([a-zA-Z0-9_.-]+)\s*$", line)
|
|
176
|
+
if simple_match:
|
|
177
|
+
pkg_name = simple_match.group(1)
|
|
178
|
+
return self._normalize_package_name(pkg_name), "*"
|
|
179
|
+
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def _parse_pyproject_toml(self, file_path: Path) -> List[Package]:
|
|
183
|
+
"""Parse pyproject.toml for dependencies.
|
|
184
|
+
|
|
185
|
+
Supports:
|
|
186
|
+
- [project.dependencies] (PEP 621)
|
|
187
|
+
- [project.optional-dependencies]
|
|
188
|
+
- [tool.poetry.dependencies]
|
|
189
|
+
- [tool.poetry.dev-dependencies]
|
|
190
|
+
"""
|
|
191
|
+
packages: List[Package] = []
|
|
192
|
+
content = file_path.read_text(encoding="utf-8")
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
data = tomllib.loads(content)
|
|
196
|
+
except Exception:
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
# PEP 621 style: [project.dependencies]
|
|
200
|
+
project = data.get("project", {})
|
|
201
|
+
deps = project.get("dependencies", [])
|
|
202
|
+
for dep in deps:
|
|
203
|
+
parsed = self._parse_requirement_line(dep)
|
|
204
|
+
if parsed:
|
|
205
|
+
name, version = parsed
|
|
206
|
+
packages.append(
|
|
207
|
+
Package(
|
|
208
|
+
name=name,
|
|
209
|
+
version=version,
|
|
210
|
+
ecosystem=self.ecosystem,
|
|
211
|
+
source_file=file_path,
|
|
212
|
+
source_line=None,
|
|
213
|
+
is_direct=True,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# PEP 621 optional dependencies
|
|
218
|
+
optional_deps = project.get("optional-dependencies", {})
|
|
219
|
+
for _group, deps_list in optional_deps.items():
|
|
220
|
+
for dep in deps_list:
|
|
221
|
+
parsed = self._parse_requirement_line(dep)
|
|
222
|
+
if parsed:
|
|
223
|
+
name, version = parsed
|
|
224
|
+
packages.append(
|
|
225
|
+
Package(
|
|
226
|
+
name=name,
|
|
227
|
+
version=version,
|
|
228
|
+
ecosystem=self.ecosystem,
|
|
229
|
+
source_file=file_path,
|
|
230
|
+
source_line=None,
|
|
231
|
+
is_direct=True,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Poetry style: [tool.poetry.dependencies]
|
|
236
|
+
tool = data.get("tool", {})
|
|
237
|
+
poetry = tool.get("poetry", {})
|
|
238
|
+
poetry_deps = poetry.get("dependencies", {})
|
|
239
|
+
for name, spec in poetry_deps.items():
|
|
240
|
+
if name.lower() == "python":
|
|
241
|
+
continue
|
|
242
|
+
version = self._extract_poetry_version(spec)
|
|
243
|
+
packages.append(
|
|
244
|
+
Package(
|
|
245
|
+
name=self._normalize_package_name(name),
|
|
246
|
+
version=version,
|
|
247
|
+
ecosystem=self.ecosystem,
|
|
248
|
+
source_file=file_path,
|
|
249
|
+
source_line=None,
|
|
250
|
+
is_direct=True,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Poetry dev dependencies
|
|
255
|
+
dev_deps = poetry.get("dev-dependencies", {})
|
|
256
|
+
for name, spec in dev_deps.items():
|
|
257
|
+
version = self._extract_poetry_version(spec)
|
|
258
|
+
packages.append(
|
|
259
|
+
Package(
|
|
260
|
+
name=self._normalize_package_name(name),
|
|
261
|
+
version=version,
|
|
262
|
+
ecosystem=self.ecosystem,
|
|
263
|
+
source_file=file_path,
|
|
264
|
+
source_line=None,
|
|
265
|
+
is_direct=True,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Poetry group dependencies (poetry 1.2+)
|
|
270
|
+
groups = poetry.get("group", {})
|
|
271
|
+
for _group_name, group_data in groups.items():
|
|
272
|
+
group_deps = group_data.get("dependencies", {})
|
|
273
|
+
for name, spec in group_deps.items():
|
|
274
|
+
version = self._extract_poetry_version(spec)
|
|
275
|
+
packages.append(
|
|
276
|
+
Package(
|
|
277
|
+
name=self._normalize_package_name(name),
|
|
278
|
+
version=version,
|
|
279
|
+
ecosystem=self.ecosystem,
|
|
280
|
+
source_file=file_path,
|
|
281
|
+
source_line=None,
|
|
282
|
+
is_direct=True,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return packages
|
|
287
|
+
|
|
288
|
+
def _extract_poetry_version(self, spec: Any) -> str:
|
|
289
|
+
"""Extract version from Poetry dependency specification."""
|
|
290
|
+
if isinstance(spec, str):
|
|
291
|
+
# Simple version string: "^1.0.0" or ">=1.0,<2.0"
|
|
292
|
+
return spec.lstrip("^~")
|
|
293
|
+
elif isinstance(spec, dict):
|
|
294
|
+
# Complex specification: {version = "^1.0.0", optional = true}
|
|
295
|
+
version = spec.get("version", "*")
|
|
296
|
+
return version.lstrip("^~") if isinstance(version, str) else "*"
|
|
297
|
+
return "*"
|
|
298
|
+
|
|
299
|
+
def _parse_pipfile(self, file_path: Path) -> List[Package]:
|
|
300
|
+
"""Parse Pipfile for dependencies."""
|
|
301
|
+
packages: List[Package] = []
|
|
302
|
+
content = file_path.read_text(encoding="utf-8")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
data = tomllib.loads(content)
|
|
306
|
+
except Exception:
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
# Parse [packages] section
|
|
310
|
+
pkgs = data.get("packages", {})
|
|
311
|
+
for name, spec in pkgs.items():
|
|
312
|
+
version = self._extract_pipfile_version(spec)
|
|
313
|
+
packages.append(
|
|
314
|
+
Package(
|
|
315
|
+
name=self._normalize_package_name(name),
|
|
316
|
+
version=version,
|
|
317
|
+
ecosystem=self.ecosystem,
|
|
318
|
+
source_file=file_path,
|
|
319
|
+
source_line=None,
|
|
320
|
+
is_direct=True,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Parse [dev-packages] section
|
|
325
|
+
dev_pkgs = data.get("dev-packages", {})
|
|
326
|
+
for name, spec in dev_pkgs.items():
|
|
327
|
+
version = self._extract_pipfile_version(spec)
|
|
328
|
+
packages.append(
|
|
329
|
+
Package(
|
|
330
|
+
name=self._normalize_package_name(name),
|
|
331
|
+
version=version,
|
|
332
|
+
ecosystem=self.ecosystem,
|
|
333
|
+
source_file=file_path,
|
|
334
|
+
source_line=None,
|
|
335
|
+
is_direct=True,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return packages
|
|
340
|
+
|
|
341
|
+
def _extract_pipfile_version(self, spec: Any) -> str:
|
|
342
|
+
"""Extract version from Pipfile dependency specification."""
|
|
343
|
+
if isinstance(spec, str):
|
|
344
|
+
if spec == "*":
|
|
345
|
+
return "*"
|
|
346
|
+
# Remove operators
|
|
347
|
+
return re.sub(r"^[=<>~!]+", "", spec)
|
|
348
|
+
elif isinstance(spec, dict):
|
|
349
|
+
version = spec.get("version", "*")
|
|
350
|
+
if version == "*":
|
|
351
|
+
return "*"
|
|
352
|
+
return re.sub(r"^[=<>~!]+", "", version)
|
|
353
|
+
return "*"
|
|
354
|
+
|
|
355
|
+
def _parse_poetry_lock(self, file_path: Path) -> List[Package]:
|
|
356
|
+
"""Parse poetry.lock for transitive dependencies."""
|
|
357
|
+
packages: List[Package] = []
|
|
358
|
+
content = file_path.read_text(encoding="utf-8")
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
data = tomllib.loads(content)
|
|
362
|
+
except Exception:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
# Parse [[package]] entries
|
|
366
|
+
pkg_list = data.get("package", [])
|
|
367
|
+
for pkg in pkg_list:
|
|
368
|
+
name = pkg.get("name", "")
|
|
369
|
+
version = pkg.get("version", "*")
|
|
370
|
+
if name:
|
|
371
|
+
packages.append(
|
|
372
|
+
Package(
|
|
373
|
+
name=self._normalize_package_name(name),
|
|
374
|
+
version=version,
|
|
375
|
+
ecosystem=self.ecosystem,
|
|
376
|
+
source_file=file_path,
|
|
377
|
+
source_line=None,
|
|
378
|
+
is_direct=False, # Lock file = transitive dependencies
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return packages
|
|
383
|
+
|
|
384
|
+
def _parse_pipfile_lock(self, file_path: Path) -> List[Package]:
|
|
385
|
+
"""Parse Pipfile.lock for transitive dependencies."""
|
|
386
|
+
packages: List[Package] = []
|
|
387
|
+
content = file_path.read_text(encoding="utf-8")
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
data = json.loads(content)
|
|
391
|
+
except json.JSONDecodeError:
|
|
392
|
+
return []
|
|
393
|
+
|
|
394
|
+
# Parse "default" section (production dependencies)
|
|
395
|
+
default = data.get("default", {})
|
|
396
|
+
for name, spec in default.items():
|
|
397
|
+
version = self._extract_pipfile_lock_version(spec)
|
|
398
|
+
packages.append(
|
|
399
|
+
Package(
|
|
400
|
+
name=self._normalize_package_name(name),
|
|
401
|
+
version=version,
|
|
402
|
+
ecosystem=self.ecosystem,
|
|
403
|
+
source_file=file_path,
|
|
404
|
+
source_line=None,
|
|
405
|
+
is_direct=False,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Parse "develop" section (dev dependencies)
|
|
410
|
+
develop = data.get("develop", {})
|
|
411
|
+
for name, spec in develop.items():
|
|
412
|
+
version = self._extract_pipfile_lock_version(spec)
|
|
413
|
+
packages.append(
|
|
414
|
+
Package(
|
|
415
|
+
name=self._normalize_package_name(name),
|
|
416
|
+
version=version,
|
|
417
|
+
ecosystem=self.ecosystem,
|
|
418
|
+
source_file=file_path,
|
|
419
|
+
source_line=None,
|
|
420
|
+
is_direct=False,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return packages
|
|
425
|
+
|
|
426
|
+
def _extract_pipfile_lock_version(self, spec: Dict) -> str:
|
|
427
|
+
"""Extract version from Pipfile.lock entry."""
|
|
428
|
+
version = spec.get("version", "*")
|
|
429
|
+
if version.startswith("=="):
|
|
430
|
+
return version[2:]
|
|
431
|
+
return version
|
|
432
|
+
|
|
433
|
+
def _normalize_package_name(self, name: str) -> str:
|
|
434
|
+
"""Normalize Python package name (PEP 503)."""
|
|
435
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|